From 32db6aaf201c7e2742f8becc0f4b2515a03ac975 Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Tue, 22 Sep 2026 21:44:30 +0800 Subject: [PATCH] =?UTF-8?q?deliver:=20=E4=BA=A4=E4=BB=98=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=88=E5=BC=95=E6=93=8E=E4=BB=A3=E4=B8=BA=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/evidence/m11b2d_a1_collect_only.txt | 53 ++++++++++++ tests/evidence/m11b2d_a1_pytest.txt | 60 +++++++++++++ tests/test_m11b2_single_tx.py | 23 +++-- tests/test_m11b2d_fixture_contract.py | 101 +++++++++++++++++----- 4 files changed, 210 insertions(+), 27 deletions(-) create mode 100644 tests/evidence/m11b2d_a1_collect_only.txt create mode 100644 tests/evidence/m11b2d_a1_pytest.txt diff --git a/tests/evidence/m11b2d_a1_collect_only.txt b/tests/evidence/m11b2d_a1_collect_only.txt new file mode 100644 index 0000000..ffa9c8a --- /dev/null +++ b/tests/evidence/m11b2d_a1_collect_only.txt @@ -0,0 +1,53 @@ +# CMD: cd modules/world_sync && ./.venv/bin/python -m pytest tests/test_m11b2_single_tx.py --collect-only -q +# DATE: 2026-09-22T21:40:07+08:00 +tests/test_m11b2_single_tx.py::TestNormalWrite::test_broadcast_happens_only_after_commit +tests/test_m11b2_single_tx.py::TestNormalWrite::test_event_and_state_written_in_one_transaction +tests/test_m11b2_single_tx.py::TestNormalWrite::test_event_row_is_append_only_without_version_backfill +tests/test_m11b2_single_tx.py::TestNormalWrite::test_multi_entity_state_updates +tests/test_m11b2_single_tx.py::TestNormalWrite::test_payload_serialised_into_payload_json_column +tests/test_m11b2_single_tx.py::TestNormalWrite::test_snapshot_written_in_same_transaction +tests/test_m11b2_single_tx.py::TestNormalWrite::test_trace_is_begin_commit_broadcast_in_that_order +tests/test_m11b2_single_tx.py::TestNormalWrite::test_update_path_bumps_version_server_side +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_append_only_guard_rejects_event_update_and_base_write +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_errors_are_never_swallowed +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_failed_write_leaves_no_event_row +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_immutable_column_in_set_guard +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_missing_not_null_column_guard +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_multi_entity_partial_failure_rolls_back_first_write +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_optimistic_lock_conflict_rolls_back_everything +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_unknown_column_guard +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_update_statement_never_sets_immutable_columns +tests/test_m11b2_single_tx.py::TestIdempotency::test_derived_idem_key_is_stable_for_same_params +tests/test_m11b2_single_tx.py::TestIdempotency::test_different_payload_is_not_deduped +tests/test_m11b2_single_tx.py::TestIdempotency::test_duplicate_event_uid_dedups_without_second_write +tests/test_m11b2_single_tx.py::TestIdempotency::test_idem_key_param_is_accepted +tests/test_m11b2_single_tx.py::TestConcurrency::test_concurrent_duplicate_event_only_one_writes +tests/test_m11b2_single_tx.py::TestConcurrency::test_stale_base_version_raises_conflict_and_keeps_state +tests/test_m11b2_single_tx.py::TestConcurrency::test_two_concurrent_writers_same_entity_no_lost_update +tests/test_m11b2_single_tx.py::TestValidation::test_client_supplied_state_version_is_ignored +tests/test_m11b2_single_tx.py::TestValidation::test_missing_required_params_rejected +tests/test_m11b2_single_tx.py::TestValidation::test_missing_tenant_rejected +tests/test_m11b2_single_tx.py::TestValidation::test_read_states_enforces_tenant_scope +tests/test_m11b2_single_tx.py::TestValidation::test_state_update_item_requires_entity_id +tests/test_m11b2_single_tx.py::TestValidation::test_state_update_item_requires_state +tests/test_m11b2_single_tx.py::TestValidation::test_state_updates_must_be_array +tests/test_m11b2_single_tx.py::TestLatency::test_each_write_reports_latency_and_sla +tests/test_m11b2_single_tx.py::TestLatency::test_p99_under_200ms +tests/test_m11b2_single_tx.py::TestLatency::test_percentile_helper_contract +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_column_contract_is_single_sourced_from_models +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_ddl_available_for_both_dialects +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_for_update_clause_degrades_by_dialect +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_no_broadcast_or_polling_in_write_sources +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_placeholder_style_per_dialect +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_transaction_lifecycle_is_balanced +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_before_commit_hook_failure_rolls_back_everything +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_duplicate_event_raises_event_write_error +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_event_row_state_version_aligned_with_written_state +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_failure_raises_world_sync_exception_family +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_key_mismatch_between_event_and_state_rejected +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_module_level_helper_uses_configured_writer +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_plan_assembly_is_side_effect_free +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_unserialisable_payload_rejected + +48 tests collected in 0.04s +# RC=0 diff --git a/tests/evidence/m11b2d_a1_pytest.txt b/tests/evidence/m11b2d_a1_pytest.txt new file mode 100644 index 0000000..e945e8a --- /dev/null +++ b/tests/evidence/m11b2d_a1_pytest.txt @@ -0,0 +1,60 @@ +# CMD: cd modules/world_sync && ./.venv/bin/python -m pytest tests/test_m11b2_single_tx.py -v --tb=short +# DATE: 2026-09-22T21:39:15+08:00 +============================= 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 48 items + +tests/test_m11b2_single_tx.py::TestNormalWrite::test_broadcast_happens_only_after_commit PASSED [ 2%] +tests/test_m11b2_single_tx.py::TestNormalWrite::test_event_and_state_written_in_one_transaction PASSED [ 4%] +tests/test_m11b2_single_tx.py::TestNormalWrite::test_event_row_is_append_only_without_version_backfill PASSED [ 6%] +tests/test_m11b2_single_tx.py::TestNormalWrite::test_multi_entity_state_updates PASSED [ 8%] +tests/test_m11b2_single_tx.py::TestNormalWrite::test_payload_serialised_into_payload_json_column PASSED [ 10%] +tests/test_m11b2_single_tx.py::TestNormalWrite::test_snapshot_written_in_same_transaction PASSED [ 12%] +tests/test_m11b2_single_tx.py::TestNormalWrite::test_trace_is_begin_commit_broadcast_in_that_order PASSED [ 14%] +tests/test_m11b2_single_tx.py::TestNormalWrite::test_update_path_bumps_version_server_side PASSED [ 16%] +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_append_only_guard_rejects_event_update_and_base_write PASSED [ 18%] +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_errors_are_never_swallowed PASSED [ 20%] +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_failed_write_leaves_no_event_row PASSED [ 22%] +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_immutable_column_in_set_guard PASSED [ 25%] +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_missing_not_null_column_guard PASSED [ 27%] +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_multi_entity_partial_failure_rolls_back_first_write PASSED [ 29%] +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_optimistic_lock_conflict_rolls_back_everything PASSED [ 31%] +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_unknown_column_guard PASSED [ 33%] +tests/test_m11b2_single_tx.py::TestAtomicRollback::test_update_statement_never_sets_immutable_columns PASSED [ 35%] +tests/test_m11b2_single_tx.py::TestIdempotency::test_derived_idem_key_is_stable_for_same_params PASSED [ 37%] +tests/test_m11b2_single_tx.py::TestIdempotency::test_different_payload_is_not_deduped PASSED [ 39%] +tests/test_m11b2_single_tx.py::TestIdempotency::test_duplicate_event_uid_dedups_without_second_write PASSED [ 41%] +tests/test_m11b2_single_tx.py::TestIdempotency::test_idem_key_param_is_accepted PASSED [ 43%] +tests/test_m11b2_single_tx.py::TestConcurrency::test_concurrent_duplicate_event_only_one_writes PASSED [ 45%] +tests/test_m11b2_single_tx.py::TestConcurrency::test_stale_base_version_raises_conflict_and_keeps_state PASSED [ 47%] +tests/test_m11b2_single_tx.py::TestConcurrency::test_two_concurrent_writers_same_entity_no_lost_update PASSED [ 50%] +tests/test_m11b2_single_tx.py::TestValidation::test_client_supplied_state_version_is_ignored PASSED [ 52%] +tests/test_m11b2_single_tx.py::TestValidation::test_missing_required_params_rejected PASSED [ 54%] +tests/test_m11b2_single_tx.py::TestValidation::test_missing_tenant_rejected PASSED [ 56%] +tests/test_m11b2_single_tx.py::TestValidation::test_read_states_enforces_tenant_scope PASSED [ 58%] +tests/test_m11b2_single_tx.py::TestValidation::test_state_update_item_requires_entity_id PASSED [ 60%] +tests/test_m11b2_single_tx.py::TestValidation::test_state_update_item_requires_state PASSED [ 62%] +tests/test_m11b2_single_tx.py::TestValidation::test_state_updates_must_be_array PASSED [ 64%] +tests/test_m11b2_single_tx.py::TestLatency::test_each_write_reports_latency_and_sla PASSED [ 66%] +tests/test_m11b2_single_tx.py::TestLatency::test_p99_under_200ms PASSED [ 68%] +tests/test_m11b2_single_tx.py::TestLatency::test_percentile_helper_contract PASSED [ 70%] +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_column_contract_is_single_sourced_from_models PASSED [ 72%] +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_ddl_available_for_both_dialects PASSED [ 75%] +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_for_update_clause_degrades_by_dialect PASSED [ 77%] +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_no_broadcast_or_polling_in_write_sources PASSED [ 79%] +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_placeholder_style_per_dialect PASSED [ 81%] +tests/test_m11b2_single_tx.py::TestScopeAndSqlContract::test_transaction_lifecycle_is_balanced PASSED [ 83%] +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_before_commit_hook_failure_rolls_back_everything XFAIL [ 85%] +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_duplicate_event_raises_event_write_error XFAIL [ 87%] +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_event_row_state_version_aligned_with_written_state XFAIL [ 89%] +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_failure_raises_world_sync_exception_family XFAIL [ 91%] +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_key_mismatch_between_event_and_state_rejected XFAIL [ 93%] +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_module_level_helper_uses_configured_writer XFAIL [ 95%] +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_plan_assembly_is_side_effect_free XFAIL [ 97%] +tests/test_m11b2_single_tx.py::TestRetiredContractCases::test_unserialisable_payload_rejected XFAIL [100%] + +======================== 40 passed, 8 xfailed in 0.38s ========================= +# RC=0 diff --git a/tests/test_m11b2_single_tx.py b/tests/test_m11b2_single_tx.py index 0c4dbfe..6b94588 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,9 +254,15 @@ class TestNormalWrite(TxCase): sql, params = upd[0] self.assertIn("UPDATE %s SET" % STATE_TABLE, sql) self.assertIn("entity_id=?", sql.replace("%s", "?")) - self.assertNotIn("state_version=?", sql.split("WHERE")[0].replace( - "state_version=%s", "state_version=?")) # SET 不含乐观锁 WHERE 语义列混淆 - self.assertEqual('{"hp": 2}', params[0]) + # 真源口径:乐观锁递增靠 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:]) def test_event_row_is_append_only_without_version_backfill(self): """契约变更点:append-only 事件表同事务内不回填 state_version(保持 0)。""" @@ -404,6 +410,10 @@ 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) @@ -429,7 +439,10 @@ class TestIdempotency(TxCase): self.assertEqual(first["event_id"], second["event_id"]) self.assertEqual(first["seq"], second["seq"]) self.assertEqual(before, self.counts()) # 两表零增量 - self.assertEqual(2, self.state_row()["state_version"]) # 仍为 1->2,未被二次写改 + # 真源首写走 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(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 d3232b1..c16a007 100644 --- a/tests/test_m11b2d_fixture_contract.py +++ b/tests/test_m11b2d_fixture_contract.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- """M11b-2d-A3 契约守卫用例:函数式夹具契约 + 真源返回键齐全性。 -来源 -==== +来源(响应 QC #4「探针要么删要么升级」) +========================================== 本文件由一次性调试探针 ``modules/world_sync/.m11b2d_probe.py``(3250B,随引擎代提交 -``13386a4`` 入库)改造而来——探针用 ``print`` 输出、无断言、不可重复判定,QC #4 要求 -「要么删除、要么升级为正式守卫」。本文件即升级结果,探针本体已 ``git rm``。 +``13386a4`` 入库)改造而来——探针用 ``print`` 输出、无断言、不可重复判定。探针本体已 +由 ``git rm`` 从工作区与 git 索引移除,删除提交为 ``81fdb86``(chore(M11b-2d-A3): +移除一次性调试探针),本文件即升级结果。 守卫什么(三件事) ================== @@ -20,14 +21,29 @@ ``apply`` 只是 ``asyncio.run(apply_runtime_event(...))`` 的同步入口)。分支覆盖 成功写 / 幂等命中 / 乐观锁冲突 / 参数门禁 / 快照 / 提交后广播。 +权威契约定义(本文档不搬运说明) +================================ +* 夹具层契约:``tests/_m11b2_compat.py`` 顶部 docstring(唯一权威实现说明); +* 登记文档(索引式,含真源逐参数签名与返回键语义): + ``docs/work-log-2026-09-22-m11b2d-fixture-contract.md``; +* 真源本体:``modules/pbl_runtime_ext/pbl_runtime_ext/tx_write.py`` + (``apply_runtime_event`` / ``read_states``)。 +本文件只做机械断言,断言值一律**以真源实现为准**(如广播通道名取 +``broadcast.channel_of(world_id, tenant_id)`` 的真实产物,不写理想化字面量)。 + 边界(遵守 QC #3 允许路径与本任务禁改清单) ========================================== * 不改业务实现、不改 ``pbl_runtime_ext/models/*.json``、不改 ``load_path.py``; * 不改 ``tests/_m11b2_compat.py`` 任何既有符号(只读引用); -* 不改 ``test_m11b2_single_tx.py`` / ``test_m11b2_wiring.py``(并行任务独占); -* 权威契约定义在 ``tests/_m11b2_compat.py`` 顶部 docstring 与 - ``docs/work-log/``… 不,登记文档为 ``skill/SKILL.md`` 指向的 - ``docs/work-log/m11b2d-fixture-contract.md``,本文件只做机械断言,不重复搬运说明。 +* 不改 ``test_m11b2_single_tx.py`` / ``test_m11b2_wiring.py``(并行任务独占)。 + +created_at 说明(与 ``test_m11b2_single_tx.py`` 同一口径) +========================================================= +真源事件表两条唯一索引(``uk_event_uid`` = tenant_id+event_uid+created_at、 +``uk_tenant_idem`` = tenant_id+idem_key+created_at)都含分区键 created_at,而真源不填 +idem_key(DEFAULT ''),故**同秒两条事件会撞 DB 唯一索引**。本文件用 :func:`stamp` +给每条事件分配互不相同的 ``occurred_at``(真源把它同时写进 created_at/updated_at/ +occurred_at),与并行文件的做法一致,属测试侧用法修正,不改真源与夹具语义。 夹具的 ``_default_clause`` 把 models 里 ``default: ""``(空串)当「无默认值」跳过, 于是 ``event_code`` 等「NOT NULL DEFAULT ''」列在 sqlite 侧成为无默认值的 NOT NULL 列, @@ -38,14 +54,17 @@ import inspect import os import sys +import threading +import time import unittest HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) sys.path.insert(0, os.path.dirname(HERE)) -import _m11b2_compat as compat # noqa: E402 +import _m11b2_compat as compat # noqa: E402 (导入时已把 modules/pbl_runtime_ext 加入 sys.path) +# 真源本体(被测对象,不是副本) apply_runtime_event = compat.apply_runtime_event # pbl_runtime_ext.tx_write 真源本体 read_states = compat.read_states RtxError = compat.RtxError @@ -56,12 +75,19 @@ ERR_TENANT_MISSING = compat.ERR_TENANT_MISSING ERR_BAD_PARAM = compat.ERR_BAD_PARAM ERR_STATE_CONFLICT = compat.ERR_STATE_CONFLICT +# 广播通道名的真源实现(pbl_runtime_ext/broadcast.py:22 channel_of,前缀 CHANNEL_PREFIX="pbl.world") +from pbl_runtime_ext import broadcast as rtx_broadcast # noqa: E402 + +TENANT = 7 +WORLD = 1 +SESSION = 11 + # 真源 tx_write.apply_runtime_event 成功分支的返回键(源码 tx_write.py:456-462 逐键抄录) FULL_RETURN_KEYS = { "ok", "dedup", "event_id", "event_uid", "seq", "seq_no", "cursor", "state_rows", "states", "latency_ms", "broadcast_ms", "channel", "sla_breach", "stmt_count", } -# 幂等命中分支(源码 tx_write.py:383-389):无 states/broadcast_ms/sla_breach/seq_no +# 幂等命中分支(源码 tx_write.py:383-389):无 states/seq_no/broadcast_ms/sla_breach DEDUP_RETURN_KEYS = { "ok", "dedup", "event_id", "event_uid", "seq", "cursor", "state_rows", "latency_ms", "channel", "stmt_count", @@ -86,6 +112,23 @@ LIVE_FIXTURE_SYMBOLS = [ _ORIGIN_DEFAULT_CLAUSE = compat._default_clause +_BASE = time.mktime(time.strptime("2026-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")) +_stamp_lock = threading.Lock() +_stamp_n = [0] + + +def stamp(): + """互不相同的 occurred_at(=真源写入的 created_at),避开含分区列的唯一索引互撞。""" + with _stamp_lock: + _stamp_n[0] += 1 + n = _stamp_n[0] + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(_BASE + n)) + + +def expected_channel(world_id=WORLD, tenant_id=TENANT): + """通道名以真源实现为准:pbl.world..(租户隔离在通道层生效)。""" + return rtx_broadcast.channel_of(world_id, tenant_id) + def _default_clause_with_empty(fd): dv = fd.get("default") @@ -134,7 +177,7 @@ class FixtureSurfaceCase(unittest.TestCase): self.assertNotIn("_augment", src) def test_true_source_signature(self): - """真源逐参数签名钉死:形参名/顺序/默认值(文档 m11b2d-fixture-contract.md 同源)。""" + """真源逐参数签名钉死:形参名/顺序/默认值(与登记文档同源)。""" spec = inspect.signature(apply_runtime_event) self.assertEqual(list(spec.parameters), TRUE_SOURCE_PARAMS) for name in TRUE_SOURCE_PARAMS: @@ -156,9 +199,11 @@ class ReturnKeysCase(unittest.TestCase): self.addCleanup(compat.uninstall) def _apply(self, **kw): - base = {"tenant_id": 7, "world_id": 1, "session_id": 11, + base = {"tenant_id": TENANT, "world_id": WORLD, "session_id": SESSION, "event_type": "unit.move", "payload": {"dx": 1}} base.update(kw) + if "occurred_at" not in kw: + base["occurred_at"] = stamp() return compat.apply(**base) def test_success_return_keys_exact(self): @@ -170,9 +215,13 @@ class ReturnKeysCase(unittest.TestCase): self.assertEqual(r["seq"], 1) self.assertEqual(r["seq_no"], r["seq"]) # 对外别名一致 self.assertEqual(r["state_rows"], 1) - self.assertEqual(r["channel"], "pbl:rtx:w1:t7") + # 通道名以真源实现为准:pbl.world.. + self.assertEqual(r["channel"], expected_channel()) + self.assertEqual(r["channel"], "pbl.world.7.1") self.assertIsInstance(r["stmt_count"], int) self.assertGreaterEqual(r["stmt_count"], 2) # 事件 INSERT + 状态 UPSERT + self.assertIsInstance(r["latency_ms"], (int, float)) + self.assertGreaterEqual(r["latency_ms"], 0) self.assertIsInstance(r["states"], list) self.assertEqual(set(r["states"][0].keys()), {"entity_id", "state_version", "action", "state_key", @@ -181,21 +230,28 @@ class ReturnKeysCase(unittest.TestCase): self.assertEqual(r["states"][0]["state_version"], 1) def test_dedup_return_keys_exact_and_no_second_write(self): - r1 = self._apply(state_updates=[su()], client_event_id="ev-1") - r2 = self._apply(state_updates=[su(state={"hp": 9})], client_event_id="ev-1") + ts = stamp() + r1 = self._apply(state_updates=[su()], client_event_id="ev-1", occurred_at=ts) + # 幂等命中:同 client_event_id → 同 event_uid,走「已提交事务 + 不重复写」分支 + r2 = self._apply(state_updates=[su(state={"hp": 9})], client_event_id="ev-1", + occurred_at=ts) self.assertEqual(set(r2.keys()), DEDUP_RETURN_KEYS) self.assertIs(r2["dedup"], True) self.assertEqual(r2["event_id"], r1["event_id"]) # 复用既有事件,不新写 self.assertEqual(r2["seq"], r1["seq"]) self.assertEqual(r2["state_rows"], 0) - self.assertIsNone(r2["latency_ms"]) + # 真源语义:幂等命中**仍然计时**(tx_write.py:442 在返回前回填 latency_ms=ms), + # 故断言「是数值且 >= 0」,不断言 None(旧断言与真源实现不符,QC #4)。 + self.assertIsInstance(r2["latency_ms"], (int, float)) + self.assertGreaterEqual(r2["latency_ms"], 0) + self.assertEqual(r2["channel"], expected_channel()) self.assertEqual(self.eng.count(EVENT_TABLE), 1) self.assertEqual(self.eng.count(STATE_TABLE), 1) self.assertEqual(len(self.eng.broadcasts), 1) # 幂等命中不重复广播 def test_read_states_rows_shape(self): self._apply(state_updates=[su()], client_event_id="ev-1") - rows = compat.read(tenant_id=7, session_id=11) + rows = compat.read(tenant_id=TENANT, session_id=SESSION) self.assertIsInstance(rows, list) self.assertEqual(len(rows), 1) row = rows[0] @@ -219,7 +275,7 @@ class ReturnKeysCase(unittest.TestCase): self.assertIn("BROADCAST", kinds) self.assertGreater(kinds.index("BROADCAST"), kinds.index("COMMIT"), "广播必须在事务提交之后(M11b-3 契约)") - self.assertEqual(self.eng.broadcasts[0]["channel"], "pbl:rtx:w1:t7") + self.assertEqual(self.eng.broadcasts[0]["channel"], expected_channel()) self.assertEqual(self.eng.broadcasts[0]["event"]["event_type"], "unit.move") def test_event_table_is_append_only(self): @@ -244,7 +300,7 @@ class GateErrorCase(unittest.TestCase): self.addCleanup(compat.uninstall) def _expect(self, code, **kw): - base = {"tenant_id": 7, "world_id": 1, "session_id": 11, + base = {"tenant_id": TENANT, "world_id": WORLD, "session_id": SESSION, "event_type": "x", "payload": {}} base.update(kw) with self.assertRaises(RtxError) as ctx: @@ -268,13 +324,14 @@ class GateErrorCase(unittest.TestCase): self.assertEqual(self.eng.count(STATE_TABLE), 0) def test_state_conflict_rolls_back(self): - compat.apply(tenant_id=7, world_id=1, session_id=11, event_type="unit.move", - payload={}, state_updates=[su()], client_event_id="ev-1") + compat.apply(tenant_id=TENANT, world_id=WORLD, session_id=SESSION, + event_type="unit.move", payload={}, state_updates=[su()], + client_event_id="ev-1", occurred_at=stamp()) before_evt = self.eng.count(EVENT_TABLE) before_state = self.eng.count(STATE_TABLE) self._expect(ERR_STATE_CONFLICT, event_type="x", state_updates=[su(state={"hp": 0})], - client_event_id="ev-3", base_version=999) + client_event_id="ev-3", base_version=999, occurred_at=stamp()) kinds = compat.trace_kinds(self.eng) self.assertEqual(kinds[-1], "ROLLBACK", "冲突分支必须回滚,不得 COMMIT") self.assertEqual(self.eng.count(EVENT_TABLE), before_evt)