383 lines
18 KiB
Python
383 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""M11b-2d-A3 契约守卫用例:函数式夹具契约 + 真源返回键齐全性。
|
||
|
||
来源(响应 QC #4「探针要么删要么升级」)
|
||
==========================================
|
||
本文件由一次性调试探针 ``modules/world_sync/.m11b2d_probe.py``(3250B,随引擎代提交
|
||
``13386a4`` 入库)改造而来——探针用 ``print`` 输出、无断言、不可重复判定。探针本体已
|
||
由 ``git rm`` 从工作区与 git 索引移除,删除提交为 ``81fdb86``(chore(M11b-2d-A3):
|
||
移除一次性调试探针),本文件即升级结果。
|
||
|
||
守卫什么(三件事)
|
||
==================
|
||
1. **旧类式符号不再存在**:``RuntimeStateEventWriter`` / ``configure_writer`` /
|
||
``build_plan`` / 类式 ``write_event_with_state`` 不得再出现在夹具层
|
||
``tests/_m11b2_compat`` 上(它们曾构成第二条夹具路径,把测试钉在已删除的调用形态上,
|
||
实测 22 failed)。
|
||
2. **新函数式符号可用**:夹具只保留一条路径——``install/uninstall/apply/read/
|
||
trace_kinds/update_statements/insert_statements`` + ``RuntimeEngine``,且真源本体
|
||
``apply_runtime_event`` / ``read_states`` 经夹具直连(不是副本)。
|
||
3. **真源返回键齐全**:``apply`` 的返回值键集合**逐键断言**,不做任何增补(夹具
|
||
``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``(并行任务独占)。
|
||
|
||
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 列,
|
||
真源 INSERT(按语义不填这些列)会撞约束。本文件与 ``test_m11b2_single_tx.py`` 同口径:
|
||
**仅在构造期**临时补正空串默认值渲染,建完表立即还原,不改 compat 既有定义与行为语义。
|
||
"""
|
||
|
||
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 (导入时已把 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
|
||
EVENT_TABLE = compat.EVENT_TABLE
|
||
STATE_TABLE = compat.STATE_TABLE
|
||
SNAPSHOT_TABLE = compat.SNAPSHOT_TABLE
|
||
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/seq_no/broadcast_ms/sla_breach
|
||
DEDUP_RETURN_KEYS = {
|
||
"ok", "dedup", "event_id", "event_uid", "seq", "cursor", "state_rows",
|
||
"latency_ms", "channel", "stmt_count",
|
||
}
|
||
# 真源 apply_runtime_event 形参(源码 tx_write.py:344-348),全部默认 None
|
||
TRUE_SOURCE_PARAMS = [
|
||
"tenant_id", "world_id", "session_id", "event_type", "payload", "state_updates",
|
||
"client_event_id", "idem_key", "snapshot", "base_version", "actor_id",
|
||
"causation_id", "occurred_at", "env",
|
||
]
|
||
# 夹具层禁止复活的旧类式符号
|
||
DEAD_CLASS_STYLE_SYMBOLS = [
|
||
"RuntimeStateEventWriter", "configure_writer", "build_plan", "write_event_with_state",
|
||
]
|
||
# 夹具层必须存在的函数式入口
|
||
LIVE_FIXTURE_SYMBOLS = [
|
||
"RuntimeEngine", "install", "uninstall", "apply", "read", "trace_kinds",
|
||
"update_statements", "insert_statements", "apply_runtime_event", "read_states",
|
||
"build_row", "assert_append_only", "RtxError", "percentile",
|
||
"EVENT_TABLE", "STATE_TABLE", "SNAPSHOT_TABLE",
|
||
]
|
||
|
||
_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.<tenant>.<world>(租户隔离在通道层生效)。"""
|
||
return rtx_broadcast.channel_of(world_id, tenant_id)
|
||
|
||
|
||
def _default_clause_with_empty(fd):
|
||
dv = fd.get("default")
|
||
if isinstance(dv, str) and dv.strip() == "":
|
||
return " DEFAULT ''"
|
||
return _ORIGIN_DEFAULT_CLAUSE(fd)
|
||
|
||
|
||
def make_engine(path=":memory:"):
|
||
"""真 sqlite 事务引擎(表结构仍由夹具从 pbl_runtime_ext/models/*.json 生成)。"""
|
||
compat._default_clause = _default_clause_with_empty
|
||
try:
|
||
return compat.RuntimeEngine(path=path)
|
||
finally:
|
||
compat._default_clause = _ORIGIN_DEFAULT_CLAUSE
|
||
|
||
|
||
def su(entity="e1", state=None):
|
||
return {"entity_id": entity, "state": {"hp": 2} if state is None else state}
|
||
|
||
|
||
class FixtureSurfaceCase(unittest.TestCase):
|
||
"""夹具面:旧符号已死、新符号可直连真源。"""
|
||
|
||
def test_dead_class_style_symbols_absent(self):
|
||
for name in DEAD_CLASS_STYLE_SYMBOLS:
|
||
self.assertFalse(
|
||
hasattr(compat, name),
|
||
"夹具层不得复活旧类式符号 %s(第二条夹具路径 = M11b-2c 22 failed 根因)" % name)
|
||
|
||
def test_live_function_style_symbols_present(self):
|
||
for name in LIVE_FIXTURE_SYMBOLS:
|
||
self.assertTrue(hasattr(compat, name), "夹具缺少函数式符号 %s" % name)
|
||
self.assertTrue(callable(compat.install))
|
||
self.assertTrue(callable(compat.uninstall))
|
||
self.assertTrue(callable(compat.apply))
|
||
self.assertTrue(callable(compat.read))
|
||
|
||
def test_fixture_apply_is_thin_wrapper_on_true_source(self):
|
||
"""夹具 apply/read 不得自带实现——必须是真源的同步入口(零返回键增补)。"""
|
||
self.assertIs(compat.apply_runtime_event, apply_runtime_event)
|
||
src = inspect.getsource(compat.apply)
|
||
self.assertIn("apply_runtime_event", src)
|
||
self.assertNotIn("update(", src) # 不补键
|
||
self.assertNotIn("setdefault", src)
|
||
self.assertNotIn("_augment", src)
|
||
|
||
def test_true_source_signature(self):
|
||
"""真源逐参数签名钉死:形参名/顺序/默认值(与登记文档同源)。"""
|
||
spec = inspect.signature(apply_runtime_event)
|
||
self.assertEqual(list(spec.parameters), TRUE_SOURCE_PARAMS)
|
||
for name in TRUE_SOURCE_PARAMS:
|
||
self.assertIsNone(spec.parameters[name].default,
|
||
"形参 %s 默认值应为 None" % name)
|
||
|
||
def test_read_states_signature(self):
|
||
spec = inspect.signature(read_states)
|
||
self.assertEqual(list(spec.parameters),
|
||
["tenant_id", "session_id", "entity_ids", "world_id", "env"])
|
||
|
||
|
||
class ReturnKeysCase(unittest.TestCase):
|
||
"""真源返回键齐全性(走夹具函数式路径,断言真值,不接受 print)。"""
|
||
|
||
def setUp(self):
|
||
self.eng = make_engine()
|
||
compat.install(self.eng)
|
||
self.addCleanup(compat.uninstall)
|
||
|
||
def _apply(self, **kw):
|
||
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):
|
||
r = self._apply(state_updates=[su()], client_event_id="ev-1", actor_id="u1")
|
||
self.assertEqual(set(r.keys()), FULL_RETURN_KEYS)
|
||
self.assertIs(r["ok"], True)
|
||
self.assertIs(r["dedup"], False)
|
||
self.assertIsNotNone(r["event_id"])
|
||
self.assertEqual(r["seq"], 1)
|
||
self.assertEqual(r["seq_no"], r["seq"]) # 对外别名一致
|
||
self.assertEqual(r["state_rows"], 1)
|
||
# 通道名以真源实现为准:pbl.world.<tenant>.<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",
|
||
"state_value", "seq"})
|
||
self.assertEqual(r["states"][0]["action"], "insert")
|
||
self.assertEqual(r["states"][0]["state_version"], 1)
|
||
|
||
def test_dedup_return_keys_exact_and_no_second_write(self):
|
||
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)
|
||
# 真源语义:幂等命中**仍然计时**(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=TENANT, session_id=SESSION)
|
||
self.assertIsInstance(rows, list)
|
||
self.assertEqual(len(rows), 1)
|
||
row = rows[0]
|
||
self.assertEqual(row["entity_id"], "e1")
|
||
self.assertEqual(row["state"], {"hp": 2})
|
||
self.assertEqual(row["state_version"], 1)
|
||
self.assertIn("checksum", row)
|
||
self.assertIn("updated_at", row)
|
||
|
||
def test_snapshot_writes_snapshot_table(self):
|
||
r = self._apply(state_updates=[su(state={"hp": 5})], client_event_id="ev-4",
|
||
snapshot={"reason": "join", "hp": 5})
|
||
self.assertEqual(set(r.keys()), FULL_RETURN_KEYS)
|
||
self.assertEqual(self.eng.count(SNAPSHOT_TABLE), 1)
|
||
|
||
def test_broadcast_strictly_after_commit(self):
|
||
self._apply(state_updates=[su()], client_event_id="ev-1")
|
||
kinds = compat.trace_kinds(self.eng)
|
||
self.assertEqual(kinds[0], "BEGIN")
|
||
self.assertIn("COMMIT", kinds)
|
||
self.assertIn("BROADCAST", kinds)
|
||
self.assertGreater(kinds.index("BROADCAST"), kinds.index("COMMIT"),
|
||
"广播必须在事务提交之后(M11b-3 契约)")
|
||
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):
|
||
"""事件表只允许 INSERT:UPDATE 语句里不得出现事件表名。"""
|
||
self._apply(state_updates=[su()], client_event_id="ev-1")
|
||
self._apply(state_updates=[su(state={"hp": 3})], client_event_id="ev-2")
|
||
for sql, _p in compat.update_statements(self.eng):
|
||
self.assertNotIn(EVENT_TABLE.upper(), sql.upper(),
|
||
"事件表出现 UPDATE 语句,违反 append-only:%s" % sql)
|
||
inserts = compat.insert_statements(self.eng, EVENT_TABLE)
|
||
self.assertEqual(len(inserts), 2)
|
||
for sql, _p in inserts:
|
||
self.assertIn(EVENT_TABLE.upper(), sql.upper())
|
||
|
||
|
||
class GateErrorCase(unittest.TestCase):
|
||
"""参数门禁与乐观锁冲突:必须抛 RtxError 且 code 精确、事务回滚不留脏行。"""
|
||
|
||
def setUp(self):
|
||
self.eng = make_engine()
|
||
compat.install(self.eng)
|
||
self.addCleanup(compat.uninstall)
|
||
|
||
def _expect(self, code, **kw):
|
||
base = {"tenant_id": TENANT, "world_id": WORLD, "session_id": SESSION,
|
||
"event_type": "x", "payload": {}}
|
||
base.update(kw)
|
||
with self.assertRaises(RtxError) as ctx:
|
||
compat.apply(**base)
|
||
self.assertEqual(ctx.exception.code, code)
|
||
return ctx.exception
|
||
|
||
def test_missing_tenant_is_fail_closed(self):
|
||
self._expect(ERR_TENANT_MISSING, tenant_id=None)
|
||
self.assertEqual(self.eng.count(EVENT_TABLE), 0)
|
||
|
||
def test_missing_required_params(self):
|
||
for key in ("world_id", "session_id", "event_type"):
|
||
self._expect(ERR_BAD_PARAM, **{key: None})
|
||
self.assertEqual(self.eng.count(EVENT_TABLE), 0)
|
||
|
||
def test_bad_state_updates(self):
|
||
self._expect(ERR_BAD_PARAM, state_updates="notalist")
|
||
self._expect(ERR_BAD_PARAM, state_updates=[{"state": {"a": 1}}]) # 缺 entity_id
|
||
self._expect(ERR_BAD_PARAM, state_updates=[{"entity_id": "e1"}]) # 缺 state
|
||
self.assertEqual(self.eng.count(STATE_TABLE), 0)
|
||
|
||
def test_state_conflict_rolls_back(self):
|
||
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, occurred_at=stamp())
|
||
kinds = compat.trace_kinds(self.eng)
|
||
self.assertEqual(kinds[-1], "ROLLBACK", "冲突分支必须回滚,不得 COMMIT")
|
||
self.assertEqual(self.eng.count(EVENT_TABLE), before_evt)
|
||
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)
|