Files
AgentLab/backend/tests/test_caliber_write_guard.py
T
ThearchyHelios 2f90f0eb3a 口径台账:固定执行、验证审阅发布与增量重导入
- QueryCore 固定执行(K09):按已发布的不可变口径跑一次,交回类型化标量、发布/manifest 引用与逐查询证据
- 依赖闭包(K08):按选中的指标算出要拷的取数节点、要的入口字段,接进 derive / compose / metrics
- SQL 模板参数完整性(K01):试跑、组合问数、派生图、运行时取数共用一套语义,缺参数在渲染前就拒
- 受限字段变更服务(K03)与口径领域边界(K01):改动打在整图深拷贝上,身份与证据归属不可改
- 版本化受控模板绑定(K04):业务口径按显式绑定落到 SQL,对不上就报 unsupported / needs_update
- 验证记录(K05)与最小审阅:样例匹配不等于正式批准,审批人只从服务端身份取
- 发布依据清单(K07):发布时按不可变图物化内容寻址清单,正式用途按它判
- 增量重导入(K11)与 B/L/R 三方合并、受限自然语言修改提案(K12):只产候选,落图仍走既有写路径
- 接口与前端:口径草稿页接上字段变更、绑定状态、验证与重导入,新增 caliber-drafts / studio 检查脚本

验证:前端 tsc -b 通过。
2026-11-10 15:05:18 +08:00

478 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""K02 卡级身份、客户端版本检查与写入幂等:卡要指明、写要带基准版本、同一个 request_id 只落一次。
- 读草稿给出权威口径卡(card_id)和基准版本(revision);
- 已接管的草稿没带 expected_revision → 409 revision_required(老页面不能指望照旧写进去);
- 两个页面基于同一版先后保存:后一个 409 stale_revision 并带上 current_version,前一个写的内容不被盖掉(E01);
- 卡不在图上报 409 card_not_found;图上有好几张可写的卡又没指明目标报 409 card_required,指到哪张就改哪张(不取第一张);
- request_id:同键同内容重放不新增版本(基准版本过期也照旧成功——幂等在版本检查之前判),同键不同内容 409
idempotency_conflict;
- 没碰过的配置(取数 SQL、入口字段、报告节点、图上的扩展字段)保留;
- 画布、回滚、发布单的「保存版本」走同一条 CAS:旧写 409 stale_revision,出单之后画布又改过的发布单停在保存版本那一步;
- 没接管到工作流上的老草稿照旧只改 payload,不受这几条约束。
模型一律注入假模型(test_ledger_docdraft_api.fake_model),试跑换成写一行 succeeded 的运行(fake_trial),不发真实请求。
"""
from __future__ import annotations
import copy
import json
import uuid
from types import SimpleNamespace
import pytest
from sqlalchemy import select
from app.core.artifact_store import graph_hash
from app.db.base import SessionLocal
from app.db.models import CaliberDraft, Workflow, WorkflowVersion
from app.db.models_blueprints import Blueprint
from tests.test_ledger_docdraft_api import ( # noqa: F401 - client / documents / datasource / fake_trial 是 fixture
client, datasource, documents, extract, fake_model, fake_trial, plan_reply,
)
def cfg(graph: dict, node_id: str) -> dict:
return next(n for n in graph["nodes"] if n["id"] == node_id)["data"]["config"]
def _graph(label: str) -> dict:
return {
"nodes": [
{"id": "start", "type": "input", "position": {"x": 0, "y": 0},
"data": {"label": "输入", "config": {"fields": [{"name": "q"}]}}},
{"id": "out", "type": "output", "position": {"x": 300, "y": 0},
"data": {"label": label, "config": {"fields": [{"name": "r", "value": "{{ input.q }}"}]}}},
],
"edges": [{"source": "start", "target": "out"}],
}
async def _workflow(workflow_id: str) -> Workflow:
async with SessionLocal() as session:
return await session.get(Workflow, workflow_id)
async def _versions(workflow_id: str) -> list[int]:
async with SessionLocal() as session:
return list((await session.execute(
select(WorkflowVersion.version).where(WorkflowVersion.workflow_id == workflow_id)
.order_by(WorkflowVersion.version))).scalars())
async def _graph_of(workflow_id: str, version: int | None = None) -> dict:
async with SessionLocal() as session:
workflow = await session.get(Workflow, workflow_id)
if version is None or version == workflow.version:
return copy.deepcopy(workflow.graph)
row = (await session.execute(select(WorkflowVersion).where(
WorkflowVersion.workflow_id == workflow_id, WorkflowVersion.version == version))).scalars().one()
return copy.deepcopy(row.graph)
async def ready_draft(client, documents, datasource, monkeypatch) -> dict:
"""一份起草好、已经接管到工作流上的口径草稿(GET 的形状,带 card_id / revision)。"""
doc = await documents("guard-" + uuid.uuid4().hex)
source_id = await datasource()
fake_model(monkeypatch, plan_reply(f"{doc}-2"))
created = await extract(client, doc, source_id)
body = (await client.get(f"/api/ledger/drafts/{created['draft_id']}")).json()
assert body["status"] == "ready", body
assert body["workflow_id"], body
return body
#: 这个文件自己建的工作流和蓝图都叫这个名字开头:跑完删掉,不给后面的用例留下一堆它们不认识的行
OWN_PREFIX = "K02 "
@pytest.fixture(autouse=True)
async def cleanup_own_rows():
"""本文件建的工作流(连同版本、幂等记录、草稿)和蓝图跑完删掉:别的用例会列它们、按计数断言。
草稿本来由 documents fixture 收,这里只兜底自己的那几份(删过的再删一次是空操作)。
"""
yield
from app.db.models import CaliberWriteRequest
async with SessionLocal() as session:
workflow_ids = list((await session.execute(
select(Workflow.id).where(Workflow.name.like(f"{OWN_PREFIX}%")))).scalars())
if workflow_ids:
for model in (CaliberWriteRequest, WorkflowVersion, CaliberDraft):
rows = list((await session.execute(
select(model).where(model.workflow_id.in_(workflow_ids)))).scalars())
for row in rows:
await session.delete(row)
for workflow in list((await session.execute(
select(Workflow).where(Workflow.id.in_(workflow_ids)))).scalars()):
await session.delete(workflow)
for blueprint in list((await session.execute(
select(Blueprint).where(Blueprint.name.like(f"{OWN_PREFIX}%")))).scalars()):
await session.delete(blueprint)
await session.commit()
def patch_url(draft_id: str) -> str:
return f"/api/ledger/drafts/{draft_id}"
async def ready_draft_questions(client, documents, datasource, monkeypatch) -> dict:
"""一份带两个未决问题的草稿(问题挂在指标上,消掉它要落到图上);消问题的路径用它。"""
doc = await documents("guard-q-" + uuid.uuid4().hex)
source_id = await datasource()
plan = json.loads(plan_reply(f"{doc}-2"))
plan["metrics"][0]["open_questions"] = ["按哪个时区结算", "按哪个门店口径"]
fake_model(monkeypatch, json.dumps(plan, ensure_ascii=False))
created = await extract(client, doc, source_id)
body = (await client.get(f"/api/ledger/drafts/{created['draft_id']}")).json()
assert sorted(body["metrics"][0]["open_questions"]) == ["按哪个时区结算", "按哪个门店口径"], body
return body
# ==========================================================================
# 读草稿:卡和基准版本都是明确的
# ==========================================================================
async def test_the_draft_read_names_the_card_and_the_base_revision(client, documents, datasource, monkeypatch,
fake_trial):
body = await ready_draft(client, documents, datasource, monkeypatch)
# 起草建出来的图只有一张可写的口径卡:读的时候直接给出它的 id,客户端不用猜
assert body["card_id"] == "card"
assert [card["id"] for card in body["cards"]] == ["card"]
# revision 就是客户端要带回来的基准版本(Workflow.version)
assert body["revision"] == body["version"] == 1
assert (await _workflow(body["workflow_id"])).version == 1
async def test_a_draft_without_a_graph_keeps_the_plain_patch_path(client, monkeypatch):
"""没接管到工作流上的老草稿(连图都没有)照旧只改 payload:不要求基准版本,卡片也是空的。"""
draft_id = uuid.uuid4().hex[:32]
async with SessionLocal() as session:
session.add(CaliberDraft(id=draft_id, title="老草稿", status="ready",
payload={"metrics": [{"id": "m1", "name": "旧指标"}], "open_questions": []}))
await session.commit()
try:
res = await client.patch(patch_url(draft_id), json={"metrics": [{"id": "m1", "name": "改过的"}]})
assert res.status_code == 200, res.text
assert res.json()["metrics"][0]["name"] == "改过的"
assert res.json()["revision"] is None and res.json()["card_id"] is None
finally:
async with SessionLocal() as session:
row = await session.get(CaliberDraft, draft_id)
if row is not None:
await session.delete(row)
await session.commit()
# ==========================================================================
# 客户端版本检查:已接管的资产必须带基准版本,旧写不能盖新的
# ==========================================================================
async def test_a_taken_over_draft_refuses_a_write_without_a_base_revision(client, documents, datasource, monkeypatch,
fake_trial):
body = await ready_draft(client, documents, datasource, monkeypatch)
metrics = copy.deepcopy(body["metrics"])
metrics[0]["name"] = "没带基准版本"
res = await client.patch(patch_url(body["id"]), json={"metrics": metrics})
assert res.status_code == 409, res.text
assert res.json()["code"] == "revision_required"
assert res.json()["current_version"] == 1
# 一个版本都没多出来,图也没改
assert await _versions(body["workflow_id"]) == [1]
assert cfg(await _graph_of(body["workflow_id"]), "card")["metrics"][0]["name"] == body["metrics"][0]["name"]
async def test_a_stale_page_cannot_overwrite_the_first_save(client, documents, datasource, monkeypatch, fake_trial):
"""E01:两个页面基于同一版先后保存,后一个旧写被拒,不覆盖已保存的内容。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
first = copy.deepcopy(body["metrics"])
first[0]["name"] = "先保存的"
ok = await client.patch(patch_url(body["id"]),
json={"metrics": first, "expected_revision": body["revision"]})
assert ok.status_code == 200, ok.text
assert ok.json()["revision"] == 2 and ok.json()["metrics"][0]["name"] == "先保存的"
# 第二个页面还拿着 v1:写不进去,服务端把当前版本交回,页面上的输入由客户端保留
stale = copy.deepcopy(body["metrics"])
stale[0]["name"] = "后保存的(基于旧页面)"
old = await client.patch(patch_url(body["id"]),
json={"metrics": stale, "expected_revision": body["revision"]})
assert old.status_code == 409, old.text
assert old.json()["code"] == "stale_revision"
assert old.json()["current_version"] == 2
assert await _versions(body["workflow_id"]) == [1, 2]
current = (await client.get(patch_url(body["id"]))).json()
assert current["metrics"][0]["name"] == "先保存的" and current["revision"] == 2
async def test_the_canvas_cannot_write_a_taken_over_caliber_without_a_base_revision(
client, documents, datasource, monkeypatch, fake_trial):
"""画布也是改同一份已接管资产的入口:同样要带基准版本,不能从这里绕过去。"""
body = await ready_draft(client, documents, datasource, monkeypatch)
graph = await _graph_of(body["workflow_id"])
cfg(graph, "card")["metrics"][0]["name"] = "画布改的"
url = f"/api/workflows/{body['workflow_id']}"
refused = await client.patch(url, json={"graph": graph})
assert refused.status_code == 409, refused.text
assert refused.json()["code"] == "revision_required"
assert await _versions(body["workflow_id"]) == [1]
saved = await client.patch(url, json={"graph": graph, "expected_revision": body["revision"]})
assert saved.status_code == 200, saved.text
assert saved.json()["version"] == 2
# 旧页面(还拿着 v1)再存一次:409 stale_revision,不覆盖
cfg(graph, "card")["metrics"][0]["name"] = "又一个旧页面"
again = await client.patch(url, json={"graph": graph, "expected_revision": body["revision"]})
assert again.status_code == 409 and again.json()["code"] == "stale_revision"
assert again.json()["current_version"] == 2
# 没接管的(手建)工作流照旧可以不带基准版本保存,老客户端不受影响
plain = (await client.post("/api/workflows", json={"name": "K02 手建 " + uuid.uuid4().hex[:6],
"graph": _graph("a")})).json()
assert (await client.patch(f"/api/workflows/{plain['id']}", json={"graph": _graph("b")})).status_code == 200
# ==========================================================================
# 卡级目标:多张卡要指明,指到哪张改哪张
# ==========================================================================
async def test_a_named_card_is_the_one_that_gets_written(client, documents, datasource, monkeypatch, fake_trial):
body = await ready_draft(client, documents, datasource, monkeypatch)
wid = body["workflow_id"]
# 画布上加了第二张可写的口径卡
graph = await _graph_of(wid)
metrics = cfg(graph, "card")["metrics"]
graph["nodes"].append({"id": "card_2", "type": "metrics", "position": {"x": 0, "y": 200},
"data": {"label": "另一张卡", "config": {"metrics": [{**metrics[0], "id": "m9",
"name": "另一张卡上的指标"}]}}})
saved = await client.patch(f"/api/workflows/{wid}",
json={"graph": graph, "expected_revision": body["revision"]})
assert saved.status_code == 200, saved.text
# 读草稿仍然给出契约声明的那张卡(不是「第一张」的猜测);候选两张都列出来
fresh = (await client.get(patch_url(body["id"]))).json()
assert fresh["card_id"] == "card"
assert [card["id"] for card in fresh["cards"]] == ["card", "card_2"]
# 多张可写的卡、又不指明目标:拒(不做「取第一张」)
edited = copy.deepcopy(fresh["metrics"])
edited[0]["name"] = "没指明目标"
refused = await client.patch(patch_url(body["id"]),
json={"metrics": edited, "expected_revision": fresh["revision"]})
assert refused.status_code == 409, refused.text
assert refused.json()["code"] == "card_required"
assert [card["id"] for card in refused.json()["cards"]] == ["card", "card_2"]
# 指到没有的卡:拒
missing = await client.patch(patch_url(body["id"]),
json={"metrics": edited, "expected_revision": fresh["revision"],
"target": {"card_id": "card-nope"}})
assert missing.status_code == 409 and missing.json()["code"] == "card_not_found"
# 指到别的工作流:拒
elsewhere = await client.patch(patch_url(body["id"]),
json={"metrics": edited, "expected_revision": fresh["revision"],
"target": {"workflow_id": "wf-elsewhere", "card_id": "card_2"}})
assert elsewhere.status_code == 409 and elsewhere.json()["code"] == "target_mismatch"
# 指明 card_2:改的是它,card 一个字段不动
written = await client.patch(patch_url(body["id"]),
json={"metrics": [{"id": "m9", "name": "改在第二张卡上"}],
"expected_revision": fresh["revision"],
"target": {"workflow_id": wid, "card_id": "card_2"}})
assert written.status_code == 200, written.text
latest = await _graph_of(wid)
assert cfg(latest, "card_2")["metrics"][0]["name"] == "改在第二张卡上"
assert cfg(latest, "card")["metrics"][0]["name"] == metrics[0]["name"]
# ==========================================================================
# 幂等:同一个 request_id 只落一次
# ==========================================================================
async def test_resolved_order_does_not_look_like_another_request(client, documents, datasource, monkeypatch,
fake_trial):
"""勾选顺序变一下不该被当成另一次请求:指纹按集合归一(草稿页也把勾选去空白、去重、排序之后再发)。"""
body = await ready_draft_questions(client, documents, datasource, monkeypatch)
questions = ["按哪个时区结算", "按哪个门店口径"]
first = await client.patch(patch_url(body["id"]),
json={"resolved": questions, "expected_revision": body["revision"],
"request_id": "resolved-0001"})
assert first.status_code == 200, first.text
assert first.json()["revision"] == 2 and first.json().get("replayed") is None
# 同一批问题,顺序倒过来、还带一条重复的:同键同内容(重放),不是冲突、也不新增版本
again = await client.patch(patch_url(body["id"]),
json={"resolved": [questions[1], questions[0], questions[1]],
"expected_revision": body["revision"], "request_id": "resolved-0001"})
assert again.status_code == 200, again.text
assert again.json()["replayed"] is True
assert await _versions(body["workflow_id"]) == [1, 2]
async def test_the_same_request_id_replays_without_a_new_version(client, documents, datasource, monkeypatch, fake_trial):
body = await ready_draft(client, documents, datasource, monkeypatch)
metrics = copy.deepcopy(body["metrics"])
metrics[0]["name"] = "幂等改一次"
request = {"metrics": metrics, "expected_revision": body["revision"], "request_id": "change-0001"}
first = await client.patch(patch_url(body["id"]), json=request)
assert first.status_code == 200, first.text
assert first.json()["revision"] == 2 and first.json().get("replayed") is None
# 同键同内容重发:基准版本这时已经过期,但幂等在版本检查之前判,照旧成功,且不再新增版本
again = await client.patch(patch_url(body["id"]), json=request)
assert again.status_code == 200, again.text
assert again.json()["replayed"] is True and again.json()["revision"] == 2
assert await _versions(body["workflow_id"]) == [1, 2]
# 同键不同内容:冲突,也不新增版本
other = await client.patch(patch_url(body["id"]),
json={**request, "metrics": [{**metrics[0], "name": "换了内容"}]})
assert other.status_code == 409, other.text
assert other.json()["code"] == "idempotency_conflict"
assert await _versions(body["workflow_id"]) == [1, 2]
# 换一个键、内容也有变化:照常写一版
third = await client.patch(patch_url(body["id"]),
json={"metrics": [{**metrics[0], "name": "第二次修改"}],
"expected_revision": 2, "request_id": "change-0002"})
assert third.status_code == 200 and third.json()["revision"] == 3
assert await _versions(body["workflow_id"]) == [1, 2, 3]
# ==========================================================================
# 没碰过的配置保留
# ==========================================================================
async def test_untouched_config_survives_a_metrics_patch(client, documents, datasource, monkeypatch, fake_trial):
body = await ready_draft(client, documents, datasource, monkeypatch)
wid = body["workflow_id"]
before = await _graph_of(wid)
# 图上先有别人加的扩展字段(前端不认识的那些)
graph = copy.deepcopy(before)
graph["extensions"] = {"unknown": True}
cfg(graph, "fetch")["args"]["hints"] = {"keep": "me"}
saved = await client.patch(f"/api/workflows/{wid}",
json={"graph": graph, "expected_revision": body["revision"]})
assert saved.status_code == 200, saved.text
metrics = copy.deepcopy((await client.get(patch_url(body["id"]))).json()["metrics"])
metrics[0]["name"] = "只改名字"
res = await client.patch(patch_url(body["id"]),
json={"metrics": metrics, "expected_revision": saved.json()["version"],
"request_id": "keep-config"})
assert res.status_code == 200, res.text
after = await _graph_of(wid)
assert cfg(after, "fetch") == cfg(graph, "fetch")
assert cfg(after, "start") == cfg(graph, "start")
assert cfg(after, "write") == cfg(graph, "write")
assert after["extensions"] == {"unknown": True}
assert cfg(after, "card")["metrics"][0]["name"] == "只改名字"
assert cfg(after, "card")["metrics"][0]["expression"] == cfg(before, "card")["metrics"][0]["expression"]
# ==========================================================================
# 回滚与发布单:同一份资产、同一条 CAS
# ==========================================================================
async def test_restore_obeys_the_same_revision_rules(client):
"""回滚也是改图:已接管的资产要带基准版本,旧没带的、过期的都拒;带对了照旧回滚成新版本。"""
wid = (await client.post("/api/workflows", json={"name": "K02 回滚 " + uuid.uuid4().hex[:6],
"graph": _graph("旧图")})).json()["id"]
assert (await client.patch(f"/api/workflows/{wid}", json={"graph": _graph("新图")})).status_code == 200
# 已经接管到工作流上(文档起草建出来的口径卡)
async with SessionLocal() as session:
workflow = await session.get(Workflow, wid)
workflow.origin = {"kind": "doc_draft"}
await session.commit()
url = f"/api/workflows/{wid}/versions/1/restore"
missing = await client.post(url)
assert missing.status_code == 409, missing.text
assert missing.json()["code"] == "revision_required" and missing.json()["current_version"] == 2
stale = await client.post(url, json={"expected_revision": 1})
assert stale.status_code == 409, stale.text
assert stale.json()["code"] == "stale_revision" and stale.json()["current_version"] == 2
assert await _versions(wid) == [1, 2]
ok = await client.post(url, json={"expected_revision": 2})
assert ok.status_code == 200, ok.text
assert ok.json()["version"] == 3 and ok.json()["graph"] == _graph("旧图")
assert ok.json()["status"] == "draft"
assert await _versions(wid) == [1, 2, 3]
async def test_the_publish_plan_stops_when_the_canvas_moved_after_the_plan(client):
"""发布单的「保存版本」和画布同一条 CAS:出单之后画布又改过,就停在这一步,不把别人刚改的图盖掉。"""
from app.blueprints.publish_plan import GRAPH_CHANGED, StepFailed, _Target, _do_save_version
planned_graph = _graph("蓝图图")
wid = (await client.post("/api/workflows", json={"name": "K02 发布单 " + uuid.uuid4().hex[:6],
"graph": _graph("一开始的图")})).json()["id"]
async with SessionLocal() as session:
session.add(Blueprint(id=uuid.uuid4().hex[:32], name="K02 发布单", scope="shared",
spec={"template": {"id": "board", "version": 1}}, graph=planned_graph,
graph_hash=graph_hash(planned_graph), workflow_id=wid))
await session.commit()
planned_at = (await session.get(Workflow, wid)).version # 出单时工作流是这一版
# 出单之后画布又存了一版:这次发布单要发的还是蓝图那张图,不能盖掉画布的修改
saved = await client.patch(f"/api/workflows/{wid}",
json={"graph": _graph("画布改过的图"), "expected_revision": planned_at})
assert saved.status_code == 200 and saved.json()["version"] == 2, saved.text
async with SessionLocal() as session:
workflow = await session.get(Workflow, wid)
blueprint = (await session.execute(select(Blueprint).where(Blueprint.workflow_id == wid))).scalars().one()
ctx = SimpleNamespace(bp=blueprint, options={"workflow_revision": planned_at},
target=_Target(graph=planned_graph, digest=graph_hash(planned_graph), app_spec={},
schedule=None, workflow=workflow, version=workflow.version + 1,
reused=False, name=workflow.name))
with pytest.raises(StepFailed) as refused:
await _do_save_version(session, ctx)
assert refused.value.code == GRAPH_CHANGED
await session.rollback()
current = await _workflow(wid)
assert current.version == 2 and current.graph == _graph("画布改过的图")
assert await _versions(wid) == [1, 2]
# 按现在这一版重新出单:照常存一版(蓝图那张图)
async with SessionLocal() as session:
workflow = await session.get(Workflow, wid)
blueprint = (await session.execute(select(Blueprint).where(Blueprint.workflow_id == wid))).scalars().one()
ctx = SimpleNamespace(bp=blueprint, options={"workflow_revision": workflow.version},
target=_Target(graph=planned_graph, digest=graph_hash(planned_graph), app_spec={},
schedule=None, workflow=workflow, version=workflow.version + 1,
reused=False, name=workflow.name))
out = await _do_save_version(session, ctx)
assert out == {"workflow_id": wid, "version": 3, "graph_hash": graph_hash(planned_graph), "reused": False}
assert (await _workflow(wid)).graph == planned_graph
assert await _versions(wid) == [1, 2, 3]
async def test_the_shared_cas_refuses_a_stale_base_revision(client):
"""save_graph / append_version 是同一条 CAS:基准版本过期、或者另一处先提交了新版本,都是 StaleRevision。"""
from app.apps.versions import StaleRevision, append_version, save_graph
wid = (await client.post("/api/workflows", json={"name": "K02 CAS " + uuid.uuid4().hex[:6],
"graph": _graph("a")})).json()["id"]
async with SessionLocal() as session:
workflow = await session.get(Workflow, wid)
with pytest.raises(StaleRevision) as stale:
await save_graph(session, workflow, _graph("b"), expected_revision=workflow.version + 1)
assert stale.value.current == 1
await session.rollback()
async with SessionLocal() as first, SessionLocal() as second:
stale_row = await first.get(Workflow, wid)
current = await second.get(Workflow, wid)
await append_version(second, current, _graph("先到的"), note="先到的")
await second.commit()
with pytest.raises(StaleRevision) as lost:
await append_version(first, stale_row, _graph("后到的"), note="后到的")
assert lost.value.current == 2
await first.rollback()
assert (await _workflow(wid)).graph == _graph("先到的")
assert await _versions(wid) == [1, 2]