diff --git a/docs/work-log-2026-09-22-m11b2d-fixture-contract.md b/docs/work-log-2026-09-22-m11b2d-fixture-contract.md new file mode 100644 index 0000000..86dd367 --- /dev/null +++ b/docs/work-log-2026-09-22-m11b2d-fixture-contract.md @@ -0,0 +1,293 @@ +# M11b-2d-A3 登记:world_sync 测试夹具「函数式契约」(唯一权威登记文档) + +- **任务**: [M11b-2d-A3] 模块仓库卫生:删一次性探针 + 登记函数式夹具契约 +- **日期**: 2026-09-22 +- **响应**: QC #2(登记文档缺失);同时承载 QC #1(越权回退)与 QC #3(证据错位)的说明与移交 +- **文档定位**: **索引式登记**。夹具层实现说明的权威正文在 `tests/_m11b2_compat.py` 顶部 + docstring(不双写搬运);真源行为的权威定义在 `modules/pbl_runtime_ext/pbl_runtime_ext/tx_write.py` + 源码本身。本文档只做**符号清单 + 逐参数签名 + 返回键语义 + 错误码含义 + 废弃清单 + 引用规约**, + 全部内容由本次实读源码抄录(锚点行号见各节,行号以 2026-09-22 HEAD 为准)。 + +--- + +## 0. 一句话结论 + +M11b-2a 把运行时写入实现迁到**函数式 API** `pbl_runtime_ext.tx_write.apply_runtime_event` / +`read_states` 之后,`world_sync` 测试层**只允许存在一条夹具路径**: +`tests/_m11b2_compat.py` 的 `install/uninstall/apply/read/trace_kinds/update_statements/insert_statements` +(真 sqlite + monkeypatch `rtx_db` 三入口,直连真源代码路径)。 +旧的「类式垫片」第二路径(`RuntimeStateEventWriter` / `configure_writer` / `build_plan` / +类式 `write_event_with_state` 转发)已在 M11b-2d 删除,**禁止复活**(机械守卫见 §6)。 + +--- + +## 1. 夹具层符号清单(`modules/world_sync/tests/_m11b2_compat.py`) + +### 1.1 现行唯一夹具路径(公开符号,必须存在) + +| 符号 | 定义行 | 签名 | 职责 | +|---|---|---|---| +| `RuntimeEngine` | :166 | `RuntimeEngine(path=":memory:")` | 真 sqlite3 引擎;表结构由 `ddl_statements()` 从 `pbl_runtime_ext/models/*.json` 生成;持有 `sql_log` / `trace` / `broadcasts` 三条录制面 | +| `install` | :391 | `install(engine)` | 把 `rtx_db.transaction` / `rtx_db.dbname` / `rtx_db.q_all` 换成 sqlite 引擎实现,并把 `broadcast.hub.publish` 包成录制版;返回 engine | +| `uninstall` | :418 | `uninstall()` | 逐样还原被 patch 的三入口(`_ORIG` 表驱动),幂等(未安装直接返回) | +| `apply` | :429 | `apply(**kwargs)` → dict | `asyncio.run(apply_runtime_event(**kwargs))` 的同步入口,**零返回键增补** | +| `read` | :434 | `read(**kwargs)` → list | `asyncio.run(read_states(**kwargs))` 的同步入口 | +| `trace_kinds` | :438 | `trace_kinds(engine)` → list[str] | 事务生命周期轨迹(`BEGIN`/`COMMIT`/`ROLLBACK`/`BROADCAST`) | +| `update_statements` | :442 | `update_statements(engine)` → list[(sql, params)] | 引擎实际发出的 `UPDATE` 语句(大写、压空格),供乐观锁/不可变列断言 | +| `insert_statements` | :447 | `insert_statements(engine, table)` → list[(sql, params)] | 指定表的 `INSERT` 语句,供 append-only 断言 | + +### 1.2 夹具层直连的真源符号(re-export,不是副本) + +| 夹具名 | 来源 | 定义行 | +|---|---|---| +| `apply_runtime_event` | `pbl_runtime_ext.tx_write` | tx_write.py:344 | +| `read_states` | `pbl_runtime_ext.tx_write` | tx_write.py:466 | +| `build_row` | `pbl_runtime_ext.tx_write` | tx_write.py:157 | +| `assert_append_only` | `pbl_runtime_ext.tx_write` | tx_write.py:259 | +| `RtxError` | `pbl_runtime_ext.rtx_db` | rtx_db.py:29 | +| `percentile` | `world_sync.pbl_runtime_sql`(真源唯一实现) | — | +| `SQLITE` / `MYSQL` | `ps.DIALECT_SQLITE` / `ps.DIALECT_MYSQL` | — | +| `EVENT_TABLE` / `STATE_TABLE` / `SNAPSHOT_TABLE` / `PROTECTED_BASE_TABLES` | `pbl_runtime_ext.m11b_config` | m11b_config.py:15-17,24 | +| `ERR_*` 常量 | `pbl_runtime_ext.m11b_config` | m11b_config.py:36-41 | + +表名真值(禁在测试里写第二份字面量):`pbl_runtime_event` / `pbl_entity_state` / +`pbl_world_state_snapshot`;基表只读域 `world, scene, entity, scense, scense_runtime, script_engine`。 + +### 1.3 夹具内部辅助(私有,测试不直接依赖) + +`_sql_type`(:91) / `_not_null`(:105) / `_default_clause`(:111) / `model_fields`(:125) / +`ddl_statements`(:132) / `_to_qmark`(:160) / `_Tx`(:262, `rtx_db.transaction` 的 sqlite 替身)。 + +> ⚠️ 已知夹具渲染口径(写测试前必读):`_default_clause`(:111) 把 models 里 `default: ""` +> (空串)当「无默认值」跳过,于是 `event_code` / `idem_key` 等「NOT NULL DEFAULT ''」列在 +> sqlite 侧变成**无默认值的 NOT NULL 列**,真源按语义不填这些列时会撞约束。 +> 现行做法(`test_m11b2_single_tx.py` 与 `test_m11b2d_fixture_contract.py` 一致): +> **仅在构造期**临时替换 `_default_clause` 补正空串默认值,建完表立即还原, +> **不改夹具既有定义与行为语义**(夹具属本任务禁改清单)。 + +--- + +## 2. 真源 `apply_runtime_event` 逐参数签名(实读抄录) + +`modules/pbl_runtime_ext/pbl_runtime_ext/tx_write.py:344-348`,原文: + +```python +async def apply_runtime_event(tenant_id=None, world_id=None, session_id=None, + event_type=None, payload=None, state_updates=None, + client_event_id=None, idem_key=None, snapshot=None, + base_version=None, actor_id=None, causation_id=None, + occurred_at=None, env=None): +``` + +| # | 参数 | 默认 | 语义(真源行为,非推测) | +|---|---|---|---| +| 1 | `tenant_id` | `None` | **强制打头**。`_require_tenant()`(tx_write.py:246) 缺失即抛 `PBL-TENANT-0001`,fail-closed 拒绝读写 | +| 2 | `world_id` | `None` | 必填;`in (None, "")` → `PBL-PARAM-0001`(tx_write.py:355-356)。同时参与广播通道名 `channel_of(world_id, tenant_id)` | +| 3 | `session_id` | `None` | 必填;同上。会话内 `seq` 单调序号的作用域(`_next_seq`,tx_write.py:282) | +| 4 | `event_type` | `None` | 必填(假值即拒);写入时截断 `str(event_type)[:64]` | +| 5 | `payload` | `None` | 事件负载 dict;入库列 `payload_json = _dumps(payload or {})`;参与幂等键计算 | +| 6 | `state_updates` | `None` | **必须是数组**(`None`→`[]`;非 list/tuple 抛 `PBL-PARAM-0001`,tx_write.py:358-359)。逐项交 `_apply_state()`,每项须含 `entity_id`(或 `state_key`)+ `state`;**客户端禁写 `state_version`** | +| 7 | `client_event_id` | `None` | 幂等键来源之一:`idem_key_of(tenant_id, session_id, event_type, payload, client_event_id or idem_key)`(tx_write.py:232) | +| 8 | `idem_key` | `None` | 幂等键显式覆盖(优先级低于 `client_event_id`:`client_event_id or idem_key`) | +| 9 | `snapshot` | `None` | 传入则同事务写 `pbl_world_state_snapshot` 一行 | +| 10 | `base_version` | `None` | 乐观锁期望版本。`None` = 不做冲突校验;不匹配 → `PBL-STATE-CONFLICT` **整体回滚**(tx_write.py:313-318)。**不在 SQL 层做 version CAS**,冲突在 Python 侧裁决 | +| 11 | `actor_id` | `None` | 缺省 `"server"`,截断 `[:32]`,写 `updated_by` | +| 12 | `causation_id` | `None` | 缺省 `""`,截断 `[:32]` | +| 13 | `occurred_at` | `None` | 缺省 `time.strftime("%Y-%m-%d %H:%M:%S")`;真源把它同时写进 `created_at`/`updated_at`/`occurred_at` | +| 14 | `env` | `None` | 宿主 ServerEnv;测试路径由夹具 patch `rtx_db.dbname` 消化,不传 | + +**全部 14 个形参默认值均为 `None`**(守卫用例 `test_true_source_signature` 钉死参数名/顺序/默认值, +与本文档同源——任一处漂移即红)。 + +### 2.1 `state_updates` 单项形状 + +`_apply_state()`(tx_write.py:293) 逐项校验: + +- 非 dict → `PBL-PARAM-0001`「state_updates 每项必须是对象」 +- 缺 `entity_id`(且缺 `state_key`)→ `PBL-PARAM-0001`「state_updates 每项必须含 entity_id(客户端禁写 state_version)」;`entity_id` 截断 `[:64]` +- 无 `state`(且无 `state_value`)或值为 `None` → `PBL-PARAM-0001`「state_updates 每项必须含 state」 + +### 2.2 `read_states` 签名(tx_write.py:466-467) + +```python +async def read_states(tenant_id=None, session_id=None, entity_ids=None, + world_id=None, env=None): +``` + +`tenant_id` 强制;`session_id` 可选过滤;`entity_ids` 可选 IN 过滤;固定 `ORDER BY entity_id ASC LIMIT 500`。 + +--- + +## 3. 真源返回键语义(逐键,源码行号锚定) + +### 3.1 成功写分支(tx_write.py:456-462,14 键) + +| 键 | 语义 | +|---|---| +| `ok` | 恒 `True`(异常一律走 `RtxError`,不返回 `ok=False`) | +| `dedup` | 恒 `False`(未命中幂等) | +| `event_id` | 事件行主键(`tx.insert` 返回) | +| `event_uid` | 幂等键值(`idem_key_of` 产物) | +| `seq` / `seq_no` | 会话内单调序号;`seq_no` 是对外响应**别名**,两者恒等 | +| `cursor` | 广播游标(取 `evt.get("cursor")`,夹具路径下可为 `None`) | +| `state_rows` | `len(states)`(本次写入的状态条数) | +| `states` | `_apply_state()` 返回的逐项列表(键见 §3.4) | +| `latency_ms` | 事务全程裁决耗时(`perf_counter` 差值,`round(...,3)`);SLA 阈值 `SLA_ADJUDICATE_MS=200` | +| `broadcast_ms` | 提交后广播扇出耗时;SLA 阈值 `SLA_BROADCAST_MS=300` | +| `channel` | `broadcast.channel_of(world_id, tenant_id)` → `pbl.world..`(断言取真源产物,不写字面量) | +| `sla_breach` | `_lat.record("adjudicate", ms)` 的返回值(超阈值 True) | +| `stmt_count` | `tx.stmt_count`:**仅统计本事务实际发出的 DML**(幂等命中分支为 1,即只有那次 `SELECT` 后的 commit,见守卫实测) | + +### 3.2 幂等命中分支(tx_write.py:380-389 + :441-445,10 键) + +键集合 = `{ok, dedup, event_id, event_uid, seq, cursor, state_rows, latency_ms, channel, stmt_count}` +——**没有** `states` / `seq_no` / `broadcast_ms` / `sla_breach`。 + +- `dedup=True`,`event_id`/`seq` 复用既有事件行(不新写、不重复广播,`state_rows=0`) +- ⚠️ `latency_ms` 初值为 `None`,但真源在返回前统一回填 `dedup_result["latency_ms"] = ms` + (tx_write.py:441-442),**故实际返回数值**。旧断言「幂等分支 `latency_ms is None`」与实现不符, + 已在守卫用例改为「数值且 ≥ 0」(本次 QC #4 修正点之一)。 + +### 3.3 广播与提交次序(不可变契约) + +`await tx.commit()` 在 `with` 块内(tx_write.py:428),`hub.publish` 在块外(tx_write.py:449)—— +**广播严格晚于提交**,杜绝幽灵更新;广播失败抛 `PBL-BROAD-0001`(数据已提交不回滚,客户端 3s 轮询兜底 +`POLL_FALLBACK_MS`)。轨迹断言:`trace_kinds` 中 `index("BROADCAST") > index("COMMIT")`。 + +### 3.4 `states[]` 单项键(`_apply_state` 返回,tx_write.py:339-340) + +`{entity_id, state_version, action, state_key, state_value, seq}` +其中 `action ∈ {"insert","update"}`,`state_version` 由服务端单调递增(新建=1,命中则 `cur+1`), +`state_key`/`state_value` 是 `entity_id`/入参状态的别名(列名映射 `STATE_COL_KEY="entity_id"`、 +`STATE_COL_VALUE="state_json"`,tx_write.py:61-62)。 + +### 3.5 `read_states` 行形状 + +SELECT 列 `entity_id, state_json, state_version, checksum, updated_by, updated_at` +(tx_write.py:471-472)+ 出参加工:`state`(`_loads(state_json, {})`)、`state_value`(同值别名)、 +`entity_id`(列名对齐)。 + +--- + +## 4. `RtxError.code` 各码含义 + +`RtxError(code, msg)` 定义在 `pbl_runtime_ext/rtx_db.py:29`,属性 `.code` / `.msg`, +`str()` 形如 `[PBL-PARAM-0001] xxx`。常量集中在 `m11b_config.py:36-41`。 + +| code | 常量名 | 抛出点 | 含义与后果 | +|---|---|---|---| +| `PBL-TENANT-0001` | `ERR_TENANT_MISSING` | `_require_tenant` tx_write.py:255 | `tenant_id` 缺失,fail-closed 拒绝读写(读写两侧都校验) | +| `PBL-PARAM-0001` | `ERR_BAD_PARAM` | tx_write.py:356/359/297/300/305 | 必填缺失 / `state_updates` 非数组 / 逐项缺 `entity_id` 或缺 `state` | +| `PBL-STATE-CONFLICT` | `ERR_STATE_CONFLICT` | `_apply_state` tx_write.py:316 | `base_version` 与库中 `state_version` 不符 → **整个事务 ROLLBACK**,两表零增量 | +| `PBL-RTX-0001-APPEND` | `ERR_APPEND_ONLY` | `assert_append_only` tx_write.py:265/268 | 对 `pbl_runtime_event` 发非 INSERT/SELECT,或对基表只读域(`PROTECTED_BASE_TABLES`)写入 | +| `PBL-BROAD-0001` | `ERR_BROADCAST_AFTER_COMMIT` | tx_write.py:454 | 数据已提交但广播失败——**不回滚事实**,由 3s 轮询兜底补齐 | +| `PBL-RTX-0002` | `ERR_TX_UNSUPPORTED` | 仅 `m11b_config.py:39` 定义,`tx_write` 路径当前未抛出 | 预留给「驱动不支持事务」分支;写测试时不要假设它会出现 | +| `PBL-RTX-0003` | 字面量(tx_write.py:179/184) | `build_row` | `models/.json` 不可读,或传入未知列(防静默丢列写空行) | +| `PBL-RTX-0004` | 字面量(tx_write.py:191) | `build_row(full_row=True)` | INSERT 缺 NOT NULL 且无 DB 默认值的列(`required_columns()` 已排除自增列/`NOT NULL DEFAULT` 列) | +| `PBL-RTX-0006` | 字面量(tx_write.py:196) | `build_row(full_row=False)` | UPDATE SET 了不可变列(`tenant_id` / `id` / `created_at`) | + +夹具层同名 re-export:`ERR_UNKNOWN_COLUMN="PBL-RTX-0003"`、`ERR_MISSING_NOT_NULL="PBL-RTX-0004"`、 +`ERR_IMMUTABLE_COLUMN="PBL-RTX-0006"`(真源以字面量抛出,夹具补常量名供断言,避免测试写魔法字符串)。 + +--- + +## 5. 已废弃旧 API 清单与替代 + +| 废弃符号 | 曾存在位置 | 状态 | 替代(现行唯一路径) | +|---|---|---|---| +| `RuntimeStateEventWriter`(类式垫片) | `tests/_m11b2_compat.py`(M11b-2c 形态) | **已删除**,禁止复活 | `compat.install(RuntimeEngine())` + `compat.apply(**kw)` | +| `configure_writer` | 同上 | **已删除** | 同上(无全局配置态,夹具按用例 `setUp/tearDown` 安装/还原) | +| `build_plan` | 同上 | **已删除** | 直接传 `state_updates=[...]` 给 `apply_runtime_event`;需要看 SQL 用 `update_statements` / `insert_statements` | +| 类式 `write_event_with_state`(夹具转发路径) | 垫片方法 → `world_sync/pbl_runtime_tx.write_event_with_state` | **夹具层禁止使用** | `compat.apply(...)` → 真源 `apply_runtime_event` | +| `_augment` 补假键(`state_inserted` / `expected_version` 等) | 旧垫片 | **已删除** | 直接断言真源返回键(§3),缺键=真源没给,不再由夹具编造 | + +> 边界说明(避免误读):`write_event_with_state` 作为**业务实现函数**仍存在于 +> `world_sync/pbl_runtime_tx.py:351` 与 `world_sync/pbl_runtime_tx_sqlor.py:126`(宿主连接池版), +> 本任务禁改业务实现,未做任何删除。废弃的是**「把它当测试夹具第二条路径」**这一用法。 +> `tests/test_m11b2_wiring.py`(并行任务独占文件)仍 import 该函数,属其自身范围,本任务不碰。 + +废弃原因(实测,非推测):旧类式垫片把测试钉在「已删除的调用形态 + world_sync 本地同步实现」上, +既没验证现行真源,又与真源返回键不一致,垫片只能靠 `_augment` 补假键维持旧断言, +实测 **22 failed**(`Cannot operate on a closed database.` / `KeyError: 'expected_version'`)。 + +--- + +## 6. 机械守卫(旧符号不得复活) + +`modules/world_sync/tests/test_m11b2d_fixture_contract.py`(本任务交付物,由一次性探针 +`.m11b2d_probe.py` 升级而来,探针已由 `git rm` 移除,删除提交 `81fdb86`): + +| 组 | 用例 | 守卫内容 | +|---|---|---| +| `FixtureSurfaceCase` | `test_dead_class_style_symbols_absent` | 4 个旧类式符号不得 `hasattr(compat, ...)` | +| | `test_live_function_style_symbols_present` | 函数式入口 + 真源 re-export 必须齐备 | +| | `test_fixture_apply_is_thin_wrapper_on_true_source` | `compat.apply` 源码内不得出现 `update(`/`setdefault`/`_augment`(零增补) | +| | `test_true_source_signature` | §2 的 14 形参名/顺序/默认值 | +| | `test_read_states_signature` | §2.2 的 5 形参 | +| `ReturnKeysCase` | `test_success_return_keys_exact` | §3.1 键集合逐键 + `states[0]` 键集合(§3.4) | +| | `test_dedup_return_keys_exact_and_no_second_write` | §3.2 键集合 + 不重复写/不重复广播 | +| | `test_read_states_rows_shape` | §3.5 行形状 | +| | `test_snapshot_writes_snapshot_table` | `snapshot` 参数落 `pbl_world_state_snapshot` | +| | `test_broadcast_strictly_after_commit` | §3.3 次序契约 | +| | `test_event_table_is_append_only` | UPDATE 语句不得出现事件表名 | +| `GateErrorCase` | `test_missing_tenant_is_fail_closed` / `test_missing_required_params` / `test_bad_state_updates` / `test_state_conflict_rolls_back` | §4 各码 + 冲突分支回滚不留脏行 | + +执行证据:`tests/evidence/m11b2d_a3_fixture_contract_pytest.txt`(**15 passed**)、 +`tests/evidence/m11b2d_a3_fixture_contract_collect_only.txt`(collection error = 0)。 + +--- + +## 7. QC #1 越权变更的回退记录与移交 + +**事实**:本任务上一轮收口提交 `32db6aa`(引擎代收口)实际改动了并行任务独占文件 +`tests/test_m11b2_single_tx.py`(+23/-,两处:`state_updates` 包装成数组、乐观锁 SET/WHERE 断言口径), +与交付件正文「不改 test_m11b2_single_tx.py」的承诺矛盾。QC 判定成立。 + +**处置**:本轮以 `git apply -R`(逆向应用 `32db6aa` 中该文件的 diff)**精确剔除本任务的 23 行改动**, +不用 `git restore HEAD^` 之类粗粒度动作——因此**并行任务 A1 自己的 16 行改动(提交 `11aac66`)完整保留**, +未受任何影响。剔除后该文件相对 `81fdb86`(本任务前状态)的差异**只剩 A1 的改动**,可复核: + +``` +git diff 81fdb86 -- tests/test_m11b2_single_tx.py # 仅 A1 的 3 处外观修正 +``` + +**遗留(移交,不夹带)**:回退后 `tests/test_m11b2_single_tx.py` 实测 **8 failed, 32 passed, 8 xfailed** +(证据 `tests/evidence/m11b2d_a3_single_tx_after_revert.txt`)。失败根因是该文件仍按**旧类式夹具口径** +写断言,与真源不符,需两处修正(本任务无权改,移交该文件独占方 / 另开任务): + +1. `TxCase.apply`:`"state_updates": su() if states is None else states` → 真源要求数组, + 应为 `[su()]`(否则撞 `PBL-PARAM-0001`,并连带 5 个 `TestNormalWrite`/`TestAtomicRollback` 用例)。 +2. `test_update_path_bumps_version_server_side`:真源 `UPDATE` 把新版本写在 **SET 子句** + (`state_version=%s`),`WHERE` 只按 `tenant_id/session_id/entity_id` 定位,**不做 SQL 层 CAS**; + 旧断言「SET 不含 state_version」与实现相反,且参数序为 + `(state_json, state_version, checksum, updated_by, updated_at, tenant_id, session_id, entity_id)`。 + +本任务**只登记不修**,避免再次越权。 + +--- + +## 8. 后续任务引用规约(硬性) + +1. **新测试写运行时事务**:一律 `import _m11b2_compat as compat` → `compat.install(compat.RuntimeEngine())` + → `compat.apply(...)` / `compat.read(...)`;`addCleanup(compat.uninstall)`。**不得**新建类式垫片、 + 不得 import `world_sync.pbl_runtime_tx.write_event_with_state` 作为夹具入口。 +2. **表名 / 错误码 / 通道名**:只从 `compat` 或真源常量取(`compat.EVENT_TABLE`、`compat.ERR_BAD_PARAM`、 + `rtx_broadcast.channel_of(...)`),**禁止第二份字面量**。 +3. **返回键断言**:以本文档 §3 为准;新增键须先改真源、再同步 §3 与守卫用例(三者不一致即红)。 +4. **`occurred_at` 必须逐事件唯一**:事件表两条唯一索引含分区列 `created_at`,同秒两条会撞 DB 唯一索引 + (真源不填 `idem_key`,DEFAULT `''`)。用各测试文件自带的 `stamp()` 取互不相同时间戳。 +5. **乐观锁**:只在 Python 侧用 `base_version` 表达期望,**不要**在 SQL 里找 version CAS。 +6. **广播**:断言 `BROADCAST` 轨迹晚于 `COMMIT`;广播失败期望 `PBL-BROAD-0001` 且**数据仍在库**。 +7. **夹具不可改**:`tests/_m11b2_compat.py` 既有符号只读;需要新能力时在本文件**追加**并同步本文档 §1, + 不改既有签名与语义。 +8. **一次性调试工具禁止入库**:探针/补丁脚本一律不落在模块仓库根目录;有长期价值就升级为 `tests/` 下 + 带断言的守卫用例(本文档 §6 即先例),没有就留在工作区外或加进 `.gitignore`。 + +### 证据索引(消 QC #3「张冠李戴」) + +| 文件 | 跑的是什么 | 归属 | +|---|---|---| +| `tests/evidence/m11b2d_a3_fixture_contract_pytest.txt` | `test_m11b2d_fixture_contract.py -v`,15 passed | **本任务交付物执行证据** | +| `tests/evidence/m11b2d_a3_fixture_contract_collect_only.txt` | 同上 `--collect-only -q` | 本任务可收集性证明 | +| `tests/evidence/m11b2d_a3_single_tx_after_revert.txt` | `test_m11b2_single_tx.py -q --tb=no`,8 failed | 本任务越权回退后的真实状态(§7 移交依据) | +| `tests/evidence/m11b2d_a1_pytest.txt` / `m11b2d_a1_collect_only.txt` | `test_m11b2_single_tx.py`(48 项) | **并行任务 M11b-2d-A1 的产物证据**,非本任务交付物;本任务未改名(改名会二次触碰 A1 归档),在此登记归属 | diff --git a/tests/evidence/m11b2d_a3_fixture_contract_collect_only.txt b/tests/evidence/m11b2d_a3_fixture_contract_collect_only.txt new file mode 100644 index 0000000..d4fa85d --- /dev/null +++ b/tests/evidence/m11b2d_a3_fixture_contract_collect_only.txt @@ -0,0 +1,23 @@ +# CMD: cd modules/world_sync && ./.venv/bin/python -m pytest tests/test_m11b2d_fixture_contract.py --collect-only -q +# DATE: 2026-09-22T22:09:00+08:00 +# TASK: M11b-2d-A3 可收集性证明(collection error = 0) +tests/test_m11b2d_fixture_contract.py::FixtureSurfaceCase::test_dead_class_style_symbols_absent +tests/test_m11b2d_fixture_contract.py::FixtureSurfaceCase::test_fixture_apply_is_thin_wrapper_on_true_source +tests/test_m11b2d_fixture_contract.py::FixtureSurfaceCase::test_live_function_style_symbols_present +tests/test_m11b2d_fixture_contract.py::FixtureSurfaceCase::test_read_states_signature +tests/test_m11b2d_fixture_contract.py::FixtureSurfaceCase::test_true_source_signature +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_broadcast_strictly_after_commit +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_dedup_return_keys_exact_and_no_second_write +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_event_table_is_append_only +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_read_states_rows_shape +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_snapshot_writes_snapshot_table +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_success_return_keys_exact +tests/test_m11b2d_fixture_contract.py::GateErrorCase::test_bad_state_updates +tests/test_m11b2d_fixture_contract.py::GateErrorCase::test_missing_required_params +tests/test_m11b2d_fixture_contract.py::GateErrorCase::test_missing_tenant_is_fail_closed +tests/test_m11b2d_fixture_contract.py::GateErrorCase::test_state_conflict_rolls_back +tests/test_m11b2d_fixture_contract.py::RegistrationDocCase::test_doc_exists_and_registers_contract +tests/test_m11b2d_fixture_contract.py::RegistrationDocCase::test_doc_live_fixture_symbols_registered + +17 tests collected in 0.04s +# RC=0 diff --git a/tests/evidence/m11b2d_a3_fixture_contract_pytest.txt b/tests/evidence/m11b2d_a3_fixture_contract_pytest.txt new file mode 100644 index 0000000..972fd54 --- /dev/null +++ b/tests/evidence/m11b2d_a3_fixture_contract_pytest.txt @@ -0,0 +1,32 @@ +# CMD: cd modules/world_sync && ./.venv/bin/python -m pytest tests/test_m11b2d_fixture_contract.py -v --tb=short +# DATE: 2026-09-22T22:09:00+08:00 +# TASK: M11b-2d-A3 函数式夹具契约守卫用例(本任务交付物本体,响应 QC #3 证据错位) +# NOTE: 17 项 = 原 15 项(夹具面/返回键/门禁码三组)+ 本轮响应 QC #2 新增 RegistrationDocCase 2 项(登记文档存在性与同源校验) +# 以下为 pytest 完整原样输出 +============================= test session starts ============================== +platform linux -- Python 3.10.12, pytest-9.1.1, pluggy-1.6.0 -- /d/pipeline/workspaces/0/sdlc_general/modules/world_sync/.venv/bin/python +cachedir: .pytest_cache +rootdir: /d/pipeline/workspaces/0/sdlc_general/modules/world_sync +configfile: pyproject.toml +collecting ... collected 17 items + +tests/test_m11b2d_fixture_contract.py::FixtureSurfaceCase::test_dead_class_style_symbols_absent PASSED [ 5%] +tests/test_m11b2d_fixture_contract.py::FixtureSurfaceCase::test_fixture_apply_is_thin_wrapper_on_true_source PASSED [ 11%] +tests/test_m11b2d_fixture_contract.py::FixtureSurfaceCase::test_live_function_style_symbols_present PASSED [ 17%] +tests/test_m11b2d_fixture_contract.py::FixtureSurfaceCase::test_read_states_signature PASSED [ 23%] +tests/test_m11b2d_fixture_contract.py::FixtureSurfaceCase::test_true_source_signature PASSED [ 29%] +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_broadcast_strictly_after_commit PASSED [ 35%] +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_dedup_return_keys_exact_and_no_second_write PASSED [ 41%] +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_event_table_is_append_only PASSED [ 47%] +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_read_states_rows_shape PASSED [ 52%] +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_snapshot_writes_snapshot_table PASSED [ 58%] +tests/test_m11b2d_fixture_contract.py::ReturnKeysCase::test_success_return_keys_exact PASSED [ 64%] +tests/test_m11b2d_fixture_contract.py::GateErrorCase::test_bad_state_updates PASSED [ 70%] +tests/test_m11b2d_fixture_contract.py::GateErrorCase::test_missing_required_params PASSED [ 76%] +tests/test_m11b2d_fixture_contract.py::GateErrorCase::test_missing_tenant_is_fail_closed PASSED [ 82%] +tests/test_m11b2d_fixture_contract.py::GateErrorCase::test_state_conflict_rolls_back PASSED [ 88%] +tests/test_m11b2d_fixture_contract.py::RegistrationDocCase::test_doc_exists_and_registers_contract PASSED [ 94%] +tests/test_m11b2d_fixture_contract.py::RegistrationDocCase::test_doc_live_fixture_symbols_registered PASSED [100%] + +============================== 17 passed in 0.12s ============================== +# RC=0 diff --git a/tests/evidence/m11b2d_a3_single_tx_after_revert.txt b/tests/evidence/m11b2d_a3_single_tx_after_revert.txt new file mode 100644 index 0000000..707f51f --- /dev/null +++ b/tests/evidence/m11b2d_a3_single_tx_after_revert.txt @@ -0,0 +1,16 @@ +# CMD: cd modules/world_sync && ./.venv/bin/python -m pytest tests/test_m11b2d_single_tx.py -q --tb=no +# DATE: 2026-09-22T22:02:48+08:00 +# TASK: M11b-2d-A3 越权回退后回归基线(响应 QC #1) +# NOTE: 本任务已从收口中剔除对 test_m11b2_single_tx.py 的全部改动(git apply -R 32db6aa 的该文件 diff); +# 回退后该文件仅剩并行任务 A1 自己的 16 行改动,其 8 failed 属 A1 独占范围,本任务不修(见登记文档 §7)。 +FF..F.FFF.F........F....................xxxxxxxx [100%] +=========================== short test summary info ============================ +FAILED tests/test_m11b2_single_tx.py::TestNormalWrite::test_broadcast_happens_only_after_commit +FAILED tests/test_m11b2_single_tx.py::TestNormalWrite::test_event_and_state_written_in_one_transaction +FAILED tests/test_m11b2_single_tx.py::TestNormalWrite::test_payload_serialised_into_payload_json_column +FAILED tests/test_m11b2_single_tx.py::TestNormalWrite::test_trace_is_begin_commit_broadcast_in_that_order +FAILED tests/test_m11b2_single_tx.py::TestNormalWrite::test_update_path_bumps_version_server_side +FAILED tests/test_m11b2_single_tx.py::TestAtomicRollback::test_append_only_guard_rejects_event_update_and_base_write +FAILED tests/test_m11b2_single_tx.py::TestAtomicRollback::test_failed_write_leaves_no_event_row +FAILED tests/test_m11b2_single_tx.py::TestIdempotency::test_duplicate_event_uid_dedups_without_second_write +8 failed, 32 passed, 8 xfailed in 0.26s diff --git a/tests/test_m11b2_single_tx.py b/tests/test_m11b2_single_tx.py index e6f2749..6073548 100644 --- a/tests/test_m11b2_single_tx.py +++ b/tests/test_m11b2_single_tx.py @@ -160,7 +160,7 @@ class TxCase(unittest.TestCase): "session_id": kw.pop("session_id", SESSION), "event_type": kw.pop("event_type", "unit.move"), "payload": kw.pop("payload", {"dx": 1}), - "state_updates": [su()] if states is None else states, + "state_updates": su() if states is None else states, "actor_id": kw.pop("actor_id", "u1"), "occurred_at": kw.pop("occurred_at", stamp())} if uid is not None: @@ -254,15 +254,9 @@ class TestNormalWrite(TxCase): sql, params = upd[0] self.assertIn("UPDATE %s SET" % STATE_TABLE, sql) self.assertIn("entity_id=?", sql.replace("%s", "?")) - # 真源口径:乐观锁递增靠 SET 子句写回新版本(state_version=%s), - # WHERE 子句只按 tenant_id/session_id/entity_id 定位——**不在 SQL 层做 - # version CAS**(base_version 冲突在 Python 侧裁决后整体回滚)。 - set_clause, where_clause = sql.split("WHERE", 1) - self.assertIn("state_version=%s", set_clause) - self.assertNotIn("state_version", where_clause) - self.assertEqual('{"hp": 2}', params[0]) # state_json - self.assertEqual(4, params[1]) # state_version 写回 3+1 - self.assertEqual((TENANT, SESSION, ENTITY), params[-3:]) + self.assertNotIn("state_version=?", sql.split("WHERE")[0].replace( + "state_version=%s", "state_version=?")) # SET 不含乐观锁 WHERE 语义列混淆 + self.assertEqual('{"hp": 2}', params[0]) def test_event_row_is_append_only_without_version_backfill(self): """契约变更点:append-only 事件表同事务内不回填 state_version(保持 0)。""" @@ -410,10 +404,6 @@ class TestAtomicRollback(TxCase): self.assertEqual("ROLLBACK", self.kinds()[-1]) def test_failed_write_leaves_no_event_row(self): - # 真源语义:base_version 乐观锁只在「状态行已存在」时比对(tx_write._apply_state - # 的 if rows: 分支);无预置行会走 INSERT 分支不抛冲突。故先预置版本 1,再用 - # 过期的 base_version=42 触发 PBL-STATE-CONFLICT,才能验证「失败写入不留事件行」。 - self.eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":1}', state_version=1) before = self.counts() with self.assertRaises(RtxError): self.apply("ev-none", states=[su(state={"hp": 3})], base_version=42) @@ -439,10 +429,7 @@ class TestIdempotency(TxCase): self.assertEqual(first["event_id"], second["event_id"]) self.assertEqual(first["seq"], second["seq"]) self.assertEqual(before, self.counts()) # 两表零增量 - # 真源首写走 INSERT 分支 ⇒ state_version=1(旧类式夹具预置初值 1 才得到 2, - # 该预置口径已随 M11b-2a 迁移作废);二次写命中幂等,版本保持 1 不被改动。 - self.assertEqual(1, first["states"][0]["state_version"]) - self.assertEqual(1, self.state_row()["state_version"]) # 未被二次写改动 + self.assertEqual(2, self.state_row()["state_version"]) # 仍为 1->2,未被二次写改 self.assertEqual(bcast, len(self.eng.broadcasts)) # 不重复广播 def test_derived_idem_key_is_stable_for_same_params(self): diff --git a/tests/test_m11b2d_fixture_contract.py b/tests/test_m11b2d_fixture_contract.py index c16a007..eb537f4 100644 --- a/tests/test_m11b2d_fixture_contract.py +++ b/tests/test_m11b2d_fixture_contract.py @@ -338,5 +338,45 @@ class GateErrorCase(unittest.TestCase): self.assertEqual(self.eng.count(STATE_TABLE), before_state) +class RegistrationDocCase(unittest.TestCase): + """登记文档守卫(响应 QC #2:交付件正文引用的文档必须真实存在,不留悬空引用)。 + + 文档只做索引式登记,权威实现仍在 ``tests/_m11b2_compat.py`` docstring 与真源源码; + 本用例钉的是「登记与真源同源」:形参名、返回键、错误码、废弃符号四类清单 + 必须在文档正文中逐条出现——任一处漏登记即红。 + """ + + DOC = os.path.join(os.path.dirname(HERE), "docs", + "work-log-2026-09-22-m11b2d-fixture-contract.md") + + def _text(self): + self.assertTrue(os.path.isfile(self.DOC), + "登记文档缺失(交付件引用悬空):%s" % self.DOC) + with open(self.DOC, encoding="utf-8") as f: + return f.read() + + def test_doc_exists_and_registers_contract(self): + text = self._text() + for p in TRUE_SOURCE_PARAMS: + self.assertIn("`" + p + "`", text, "登记文档缺少真源形参 " + p) + for key in sorted(FULL_RETURN_KEYS): + self.assertIn("`" + key + "`", text, "登记文档缺少返回键 " + key) + for code in ("PBL-TENANT-0001", "PBL-PARAM-0001", "PBL-STATE-CONFLICT", + "PBL-RTX-0001-APPEND", "PBL-BROAD-0001", "PBL-RTX-0002", + "PBL-RTX-0003", "PBL-RTX-0004", "PBL-RTX-0006"): + self.assertIn(code, text, "登记文档缺少错误码 " + code + " 的含义说明") + for dead in DEAD_CLASS_STYLE_SYMBOLS: + self.assertIn(dead, text, "登记文档缺少废弃符号 " + dead + " 与替代说明") + + def test_doc_live_fixture_symbols_registered(self): + """夹具层公开符号(现行唯一路径)必须逐个登记在文档表格里。""" + text = self._text() + for name in ("RuntimeEngine", "install", "uninstall", "apply", "read", + "trace_kinds", "update_statements", "insert_statements"): + self.assertIn("`" + name + "`", text, + "登记文档缺少夹具符号 " + name + "(§1.1 清单)") + + + if __name__ == "__main__": unittest.main(verbosity=2)