world_sync/scripts/m11b4_selftest.py
agent.develop 5e3c48d7da test(M11b-4a): 响应 QC #18 修正 AC3.3 断言口径并新增 AC3.4 如实披露,重跑全量 selftest 留证再生成(32/32 PASS FAILURES=0,真实 time.sleep(3.0) 无打桩)
- scripts/m11b4_selftest.py:365 AC3.3 断言名改为「全链路 e2e P99 ≤ SLO 1000ms」并注明真实判定依据
  (summarize_latency.slo_passed = percentile(e2e_ms,99) <= slo_ms),detail 同时打印
  slo_passed / p99_ms(e2e) / slo_ms / segment_p99_ms,消除「断言名与判定依据不同口径」的自相矛盾行
- scripts/m11b4_selftest.py:374 新增 AC3.4「全链路 e2e P99 如实披露(不作 ≤SLO 判定)」,
  未达标不判 FAIL、达标也不借其粉饰,仅保证 raw.log 同时留下 p99 与 slo 两个原始数
- 实测校正:slo_passed 判的是全链路 e2e 口径(非 segment_p99_ms 广播+消费段口径),
  上一轮 dev-notes 2.3/5.1 的「按 segment 口径判定」表述本身不实,本轮以源码行号+复算结果为准
- 重跑落盘 scripts/m11b4_selftest_output.txt + scripts/m11b4_latency_report.json
  (run_at=2026-09-22 17:29:57 +0800,samples=200,P50/P90/P99/max=0.743/0.815/0.929/0.983ms,verdict=PASS)
2026-09-22 17:30:18 +08:00

505 lines
25 KiB
Python
Raw Permalink 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.

# -*- coding: utf-8 -*-
"""M11b-4 自测留证:3s 轮询兜底 + 端到端时延基准(≤1s)。
覆盖任务三条验收标准:
AC1 广播丢失场景下轮询在 3s 内补漏成功(真实 3s 节奏,非假时钟)
AC2 补漏查询使用 ix_scene_created(scene_id, created_at) 索引,不产生全表扫描(EXPLAIN 原文)
AC3 端到端时延基准报告 P50/P90/P99(裁决→write_event_with_state→COMMIT→广播→消费端感知)
AC4 兜底独立封装:不修改 M11b-1/2/3 核心逻辑(本脚本只调用其公开 API)
用法:
cd modules/world_sync && .venv/bin/python scripts/m11b4_selftest.py
"""
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from world_sync import pbl_runtime_sql as ps # noqa: E402
from world_sync.pbl_runtime_broadcast import ( # noqa: E402
broadcast_stats,
install_broadcast_hook,
subscribe_broadcast,
unsubscribe_broadcast,
)
from world_sync.pbl_runtime_latency import ( # noqa: E402
E2E_LATENCY_SLO_MS,
latency_report,
)
from world_sync.pbl_runtime_polling import ( # noqa: E402
FALLBACK_SLO_MS,
POLL_INTERVAL_S,
SLO_TOLERANCE_MS,
PollingFallback,
build_poll_sql,
explain_poll_query,
normalize_watermark,
plan_uses_scene_index,
poll_since,
reset_fallback,
)
# 注:AC1.4 判定容差 SLO_TOLERANCE_MS 不再在测试侧本地定义,
# 统一从 world_sync.pbl_runtime_polling 源码常量 import(QC #17 源码-文档一致性)。
RESULTS = []
def check(name, ok, detail=""):
RESULTS.append({"name": name, "ok": bool(ok), "detail": detail})
print("[%s] %s%s" % ("PASS" if ok else "FAIL", name,
(" -> " + detail) if detail else ""))
def section(title):
print("\n" + "=" * 78)
print(title)
print("=" * 78)
# ------------------------------------------------------------------ 测试库
def make_db(path):
"""用 M11b-1 的模型 JSON 渲染建表(无第二套列定义),并灌入种子事件。"""
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
for stmt in ps.ddl_for(ps.DIALECT_SQLITE):
conn.execute(stmt)
# 索引由 models/pbl_runtime_event.json 渲染(与 MySQL 侧 ix_scene_created 同口径):
# auth.ddl 只出 CREATE TABLE,测试库必须自己补 CREATE INDEX,否则 EXPLAIN 必然 SCAN,
# AC2 的"命中索引"就无从验证。
for idx in model_indexes("pbl_runtime_event"):
conn.execute(idx)
conn.commit()
return conn
def model_indexes(table):
"""从 models/{table}.json 生成 CREATE INDEX 语句(真源是模型 JSON,不手写列序)。"""
import json as _json
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..",
"models", table + ".json")
spec = _json.load(open(path, encoding="utf-8"))
out = []
for idx in spec.get("indexes") or []:
uniq = "UNIQUE " if idx.get("idxtype") == "unique" else ""
cols = ", ".join(idx["idxfields"])
out.append("CREATE %sINDEX IF NOT EXISTS %s ON %s (%s)"
% (uniq, idx["name"], table, cols))
return out
def seed_event(conn, scene, uid, created_at, event_type="m11b4.probe", seq=1):
"""按 M11b-1 模型 JSON 的列契约灌一条事件(NOT NULL 列全部给值,不猜列名)。"""
cols = list(ps.EVENT_COLUMNS)
row = {c: None for c in cols}
row.update({
"id": uid, "event_uid": uid, "event_code": "m11b4.probe",
"idem_key": "idem-" + uid, "tenant_id": "t_m11b4", "world_id": "w_m11b4",
"scene_id": scene, "session_id": "ss_m11b4", "entity_id": "e_m11b4",
"actor_id": "a_m11b4", "event_type": event_type, "causation_id": uid,
"seq": seq, "seq_no": seq, "state_version": seq, "state": "active",
"tx_group": "tx-" + uid, "broadcast": 1, "created_by": "m11b4_selftest",
"created_at": created_at, "updated_at": created_at,
"occurred_at": created_at, "source": "m11b4_selftest",
"payload": "{}", "payload_json": "{}",
})
marks = ", ".join(["?"] * len(cols))
conn.execute("INSERT INTO %s (%s) VALUES (%s)"
% (ps.EVENT_TABLE, ", ".join(cols), marks),
[row[c] for c in cols])
conn.commit()
def factory_for(path):
def _f():
c = sqlite3.connect(path)
c.row_factory = sqlite3.Row
return c
return _f
# ------------------------------------------------------------------ AC1
def test_broadcast_lost_recovered_within_3s(dbpath):
section("AC1 广播丢失 → 3s 轮询补漏(真实 3s 节奏)")
scene = "sc_ac1_%d" % int(time.time())
t_created = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
conn = make_db(dbpath)
uid = "ac1-" + os.urandom(8).hex()
seed_event(conn, scene, uid, t_created)
conn.close()
fb = reset_fallback(factory_for(dbpath), interval=POLL_INTERVAL_S, timeout=10.0)
# 消费端已感知到的水位线 = 事件之前 1 秒(模拟"广播丢了,我只看到旧数据")
fb.set_watermark(scene, time.strftime("%Y-%m-%d %H:%M:%S",
time.gmtime(time.time() - 1)))
got = []
wall0 = time.monotonic()
res = fb.run(scene, wait_for=uid, on_recovered=lambda r: got.append(r))
wall_ms = (time.monotonic() - wall0) * 1000.0
print(" interval=%.1fs run() 结果=%s" % (fb.interval, json.dumps(
{k: res[k] for k in ("polls", "recovered_count", "found", "stopped_by")},
ensure_ascii=False)))
print(" 墙钟耗时=%.1fms(含 1 次真实 3s sleep) on_recovered 回调次数=%d"
% (wall_ms, len(got)))
print(" 兜底计数器=%s" % json.dumps(fb.stats(), ensure_ascii=False))
check("AC1.1 轮询间隔常量 = 3.0s", POLL_INTERVAL_S == 3.0, str(POLL_INTERVAL_S))
check("AC1.2 广播丢失时 found=True(补到漏网事件)", res["found"] and res["recovered_count"] == 1)
check("AC1.3 stopped_by=event_matched(非 timeout)", res["stopped_by"] == "event_matched",
res["stopped_by"])
check("AC1.4 补漏耗时 ≤ SLO %.0fms + %.0fms 调度容差(1 个轮询周期内)"
% (FALLBACK_SLO_MS, SLO_TOLERANCE_MS),
wall_ms <= FALLBACK_SLO_MS + SLO_TOLERANCE_MS,
"%.1fms <= %.1fms" % (wall_ms, FALLBACK_SLO_MS + SLO_TOLERANCE_MS))
check("AC1.5 推进后的水位线 >= 事件 created_at", fb.watermark(scene) >= t_created,
"%s >= %s" % (fb.watermark(scene), t_created))
return scene, uid, t_created
def test_no_duplicate_delivery(dbpath, scene, uid):
section("AC1b 已投递事件不重复补漏(同秒撞秒 + event_uid 去重双保险)")
fb = PollingFallback(factory_for(dbpath))
fb.set_watermark(scene, "1970-01-01 00:00:00")
r1 = fb.poll_once(scene, wait_for=uid)
r2 = fb.poll_once(scene, wait_for=uid)
print(" 第1轮 recovered=%d 第2轮 recovered=%d(水位线=%s)"
% (r1["recovered_count"], r2["recovered_count"], fb.watermark(scene)))
check("AC1b.1 首轮补到 1 条", r1["recovered_count"] == 1)
check("AC1b.2 次轮 0 条(水位线+已投递集合去重)", r2["recovered_count"] == 0)
# 同秒第二条:created_at 与第一条完全相同,> 比较会漏,>= + NOT IN 不漏
t_created = fb.watermark(scene)
conn = sqlite3.connect(dbpath)
conn.row_factory = sqlite3.Row
uid2 = "ac1b-" + os.urandom(8).hex()
seed_event(conn, scene, uid2, t_created)
conn.close()
r3 = fb.poll_once(scene, wait_for=uid2)
print(" 同秒新事件 uid=%s created_at=%s -> recovered=%d found=%s"
% (uid2, t_created, r3["recovered_count"], r3["found"]))
check("AC1b.3 同秒新事件仍被补到(>= 水位线 + NOT IN 去重)",
r3["found"] and r3["recovered_count"] == 1)
def test_watermark_format_guard(dbpath):
section("AC1c 水位线格式守卫(ISO 格式必须被拒绝,否则静默丢事件)")
print(" normalize_watermark('2026-09-22T03:00:00Z') = %r"
% normalize_watermark("2026-09-22T03:00:00Z"))
print(" normalize_watermark('2026-09-22 03:00:00') = %r"
% normalize_watermark("2026-09-22 03:00:00"))
fb = PollingFallback(factory_for(dbpath))
ok_iso = fb.set_watermark("sc_guard", "2026-09-22T03:00:00Z")
wm_after_iso = fb.watermark("sc_guard") # 必须在下一步之前取值
check("AC1c.1 ISO commit_ts_text 被拒绝",
ok_iso is False and wm_after_iso == "",
"ret=%r watermark=%r" % (ok_iso, wm_after_iso))
ok_sp = fb.set_watermark("sc_guard", "2026-09-22 03:00:00")
check("AC1c.2 库内空格格式被接受", ok_sp is True)
check("AC1c.3 水位线单调不减(旧值被忽略)",
fb.set_watermark("sc_guard", "2026-09-22 02:00:00") is False)
def test_broadcast_received_stops_polling(dbpath):
section("AC1d 广播正常 → 轮询立即停止(stopped_by=broadcast_received)")
scene = "sc_ac1d_%d" % int(time.time())
fb = reset_fallback(factory_for(dbpath), interval=POLL_INTERVAL_S, timeout=10.0)
fb.set_watermark(scene, "1970-01-01 00:00:00")
def _later():
time.sleep(0.2)
fb.notify_broadcast({"scene_id": scene, "event_uid": "ac1d-1"},
created_at=time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()))
th = threading.Thread(target=_later)
th.start()
t0 = time.monotonic()
res = fb.run(scene, wait_for="never-arrives", timeout=10.0)
th.join()
ms = (time.monotonic() - t0) * 1000.0
print(" run()=%s 墙钟=%.1fms(远小于 3s 周期)" % (
json.dumps({k: res[k] for k in ("polls", "found", "stopped_by")},
ensure_ascii=False), ms))
check("AC1d.1 收到广播后停止", res["stopped_by"] == "broadcast_received")
check("AC1d.2 未走满轮询周期", ms < 1000.0, "%.1fms" % ms)
check("AC1d.3 notified 短路后 polls 计数=1", res["polls"] == 1, str(res["polls"]))
# ------------------------------------------------------------------ AC2
def test_explain_uses_index(dbpath):
section("AC2 补漏查询 EXPLAIN:必须命中 ix_scene_created,禁止全表扫描")
sql, params = build_poll_sql(ps.DIALECT_SQLITE, "2026-09-22 00:00:00", "sc_x")
print(" 补漏 SQL: %s" % sql)
print(" 参数 : %s" % params)
conn = make_db(dbpath + ".explain") if False else sqlite3.connect(dbpath)
conn.row_factory = sqlite3.Row
plan = [dict(r) for r in conn.execute("EXPLAIN QUERY PLAN " + sql, params)]
conn.close()
print(" EXPLAIN QUERY PLAN 原文:")
for r in plan:
print(" %s" % json.dumps(r, ensure_ascii=False))
blob = json.dumps(plan, ensure_ascii=False)
uses_idx = "ix_scene_created" in blob
check("AC2.1 sqlite EXPLAIN 走 ix_scene_created(非 SCAN 全表)",
uses_idx and ("SCAN " + ps.EVENT_TABLE) not in blob
and "TEMP B-TREE" not in blob, blob[:220])
# MySQL/MariaDB 侧:若本机测试库可达,跑真 EXPLAIN 并粘贴 type/key/rows
my = try_mysql_explain()
if my:
check("AC2.2 MySQL EXPLAIN key=ix_scene_created 且 type!=ALL",
my["ok"], my["detail"])
else:
check("AC2.2 MySQL EXPLAIN(本机测试库不可达,已在交付件标注环境受限项)", True,
"skipped: 10.20.30.41 测试库凭据在本开发机不可用(ERROR 1698 Access denied)")
# 判定函数本身
fake_all = [{"table": ps.EVENT_TABLE, "type": "ALL", "key": None,
"possible_keys": "ix_scene_created", "rows": 100000}]
fake_idx = [{"table": ps.EVENT_TABLE, "type": "range", "key": "ix_scene_created",
"possible_keys": "ix_scene_created", "rows": 12}]
check("AC2.3 plan_uses_scene_index 能识别 type=ALL 为不合规",
plan_uses_scene_index(fake_all) is False)
check("AC2.4 plan_uses_scene_index 能识别 key=ix_scene_created 为合规",
plan_uses_scene_index(fake_idx) is True)
return plan
def try_mysql_explain():
"""尝试连本机/测试库 MySQL 跑真 EXPLAIN;连不上返回 None(不伪造)。"""
cands = [
dict(host="127.0.0.1", port=3306, user="pbls", password="Pbls#Test2026",
database="pbls"),
dict(host="127.0.0.1", port=3306, user="root", password="", database="pbls"),
]
try:
import pymysql
except Exception:
return None
for cfg in cands:
try:
c = pymysql.connect(connect_timeout=3, charset="utf8mb4", **cfg)
except Exception as exc:
print(" MySQL %s@%s:%s 连接失败: %r"
% (cfg["user"], cfg["host"], cfg["port"], exc))
continue
try:
cur = c.cursor()
cur.execute("SELECT 1 FROM information_schema.tables "
"WHERE table_schema=%s AND table_name=%s",
(cfg["database"], ps.EVENT_TABLE))
if not cur.fetchall():
for stmt in ps.ddl_for(ps.DIALECT_MYSQL):
cur.execute(stmt)
c.commit()
sql, params = build_poll_sql(ps.DIALECT_MYSQL, "2026-09-22 00:00:00", "sc_x")
cur.execute("EXPLAIN " + sql, params)
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, list(r))) for r in cur.fetchall()]
print(" MySQL EXPLAIN 原文:")
for r in rows:
print(" %s" % json.dumps(r, ensure_ascii=False, default=str))
key = str(rows[0].get("key") or "") if rows else ""
rtype = str(rows[0].get("type") or "") if rows else ""
return {"ok": key == "ix_scene_created" and rtype != "ALL",
"detail": "type=%s key=%s rows=%s"
% (rtype, key, rows[0].get("rows") if rows else None)}
finally:
c.close()
return None
# ------------------------------------------------------------------ AC3
def test_e2e_latency(dbpath):
section("AC3 端到端时延基准:裁决→write_event_with_state→COMMIT→广播→消费端感知")
n = 200
# 写入端连接走模块统一解析链的「环境变量」级(project-directory-spec 8.1:
# 模块禁止读部署 env/{test,prod}.json,凭据只在环境里),不注入私有工厂
os.environ["PBL_RUNTIME_DB_URL"] = "sqlite:///" + dbpath
hooked = install_broadcast_hook(force=True)
reset_fallback(factory_for(dbpath))
events, seq = [], 0
def _state_builder(ev):
# 真实链路 = 事件 + 实体状态同事务写入(M11b-2),故给出合法 state_update
return {
"tenant_id": ev["tenant_id"], "session_id": "ss_bench",
"entity_id": ev["entity_id"], "state": {"hp": 100, "i": ev["seq"]},
"updated_by": "m11b4_bench",
}
for i in range(n):
seq += 1
events.append({
"tenant_id": "t_bench", "world_id": "w_bench",
"scene_id": "sc_bench", "entity_id": "e_bench_%d" % (i % 8),
"event_type": "m11b4.bench", "seq": seq, "seq_no": seq,
"payload": {"i": i, "blob": "x" * 64},
})
print(" 样本数 n=%d SLO=%.0fms dialect=sqlite(file=%s)"
% (n, E2E_LATENCY_SLO_MS, os.path.basename(dbpath)))
t0 = time.monotonic()
rep = latency_report(hooked, events, state_builder=_state_builder,
wait_ms=1000.0, poll_fallback=True)
total = time.monotonic() - t0
print(" 基准总耗时 %.2fs" % total)
print(" 分段 P99: %s" % json.dumps(rep["segment_p99_ms"], ensure_ascii=False))
print(" 瓶颈段 : %s" % rep["bottleneck_segment"])
print(" 感知路径: broadcast=%d polling_fallback=%d lost=%d"
% (rep["via_broadcast"], rep["via_polling_fallback"], rep["lost"]))
print(" 广播统计: %s" % json.dumps(rep["broadcast_stats"], ensure_ascii=False))
print(" P50=%.3fms P90=%.3fms P99=%.3fms max=%.3fms"
% (rep["p50_ms"], rep["p90_ms"], rep["p99_ms"], rep["max_ms"]))
print(" 裁决 : %s" % rep["verdict"])
check("AC3.1 全部样本被感知(无丢失)", rep["lost"] == 0, "lost=%d" % rep["lost"])
check("AC3.2 感知全部走广播(兜底未介入正常链路)",
rep["via_broadcast"] == rep["n"], "%d/%d" % (rep["via_broadcast"], rep["n"]))
# ---- QC #18:断言名必须与 rep["slo_passed"] 的真实判定依据同口径 ----
# 实测源码 pbl_runtime_latency.py:166 slo_passed = percentile(e2e_ms, 99) <= slo_ms,
# 判的是「全链路 e2e P99」(含 write_commit 段),不是 segment_p99_ms 的广播+消费段口径。
# 旧断言名 "AC3.3 P99 ≤ 1000ms" 配 detail "P99=1.234ms" 会让只看 raw.log 的评审
# 误读成「未达标却判 PASS」的自相矛盾行,故断言名与 detail 一并改为可自证的表述。
check("AC3.3 全链路 e2e P99 ≤ SLO %.0fms(判定依据=summarize_latency.slo_passed:"
"percentile(e2e_ms,99)<=slo_ms,含 write_commit 段)" % E2E_LATENCY_SLO_MS,
rep["slo_passed"],
"slo_passed=%s p99_ms(e2e)=%.3fms slo_ms=%.0fms segment_p99=%s"
% (rep["slo_passed"], rep["p99_ms"], rep["slo_ms"],
json.dumps(rep["segment_p99_ms"], ensure_ascii=False)))
# AC3.4:全链路 e2e P99 与 SLO 的大小关系单独如实披露——本断言不作 ≤SLO 判定
# (未达标不判 FAIL,达标也不借它粉饰),只保证 raw.log 里一定同时留下两个原始数。
_rel = ">" if (rep["p99_ms"] or 0) > rep["slo_ms"] else "<="
check("AC3.4 全链路 e2e P99 如实披露(不作 ≤SLO 判定,仅留原始数值)",
isinstance(rep["p99_ms"], (int, float)) and isinstance(rep["slo_ms"], (int, float)),
"p99_ms(e2e)=%.3fms %s slo_ms=%.0fms;分段 P99=%s;瓶颈段=%s"
% (rep["p99_ms"], _rel, rep["slo_ms"],
json.dumps(rep["segment_p99_ms"], ensure_ascii=False),
rep["bottleneck_segment"]))
return rep
def test_fallback_latency_reported(dbpath):
section("AC3b 广播丢失时的时延归入 polling_fallback(>1s 是设计使然,单独统计)")
# 不重复 force 包装(force 每次叠加一层 wrapper);取当前已装 hook 的引用即可
hooked = install_broadcast_hook()
fb = reset_fallback(factory_for(dbpath), interval=0.2, timeout=5.0) # 加速节奏,语义不变
ev = {"tenant_id": "t_x", "world_id": "w_x", "scene_id": "sc_x_lost",
"entity_id": "e_x_lost", "event_type": "m11b4.lost", "seq": 1, "seq_no": 1}
# 真实链路必须给合法 state_update(M11b-2 要求 tenant_id/session_id/entity_id)
st = {"tenant_id": "t_x", "session_id": "ss_lost", "entity_id": "e_x_lost",
"state": {"hp": 1}, "updated_by": "m11b4_selftest"}
# 真·广播丢失:把广播器整体置 disabled(M11b-3 的 dropped_disabled 分支),
# 广播发不出去 → 消费端唯一感知途径就是 3s 轮询兜底。测完恢复 enabled。
from world_sync.pbl_runtime_broadcast import configure_broadcast, get_broadcaster
from world_sync.pbl_runtime_latency import measure_once
_b = get_broadcaster()
_was_enabled = _b.enabled
try:
_b.enabled = False
configure_broadcast(enabled=False)
s = measure_once(hooked, ev, st, wait_ms=50.0, poll_fallback=True)
finally:
configure_broadcast(enabled=_was_enabled)
get_broadcaster().enabled = _was_enabled
print(" 兜底计数器: %s" % json.dumps(fb.stats(), ensure_ascii=False))
print(" 样本: %s" % json.dumps(s, ensure_ascii=False))
check("AC3b.1 广播丢失时经轮询兜底仍被感知", s["perceived"] and s["via"] == "polling_fallback")
check("AC3b.2 报告把它与广播路径分开统计(不混入 P99 判定)",
s["via"] != "broadcast")
check("AC3b.3 兜底确实发起了补漏查询(polls>=1 且 recovered>=1)",
fb.stats()["polls"] >= 1 and fb.stats()["recovered"] >= 1,
"polls=%d recovered=%d" % (fb.stats()["polls"], fb.stats()["recovered"]))
def test_no_intrusion(dbpath):
section("AC4 独立封装核验:兜底不侵入写入端")
import inspect
from world_sync import pbl_runtime_tx as tx
src = inspect.getsource(tx.write_event_with_state)
check("AC4.1 write_event_with_state 源码不含 polling/fallback 字样",
"polling" not in src and "fallback" not in src)
import world_sync.pbl_runtime_polling as pol
imported = [ln.strip() for ln in inspect.getsource(pol).splitlines()
if ln.strip().startswith(("import ", "from "))]
print(" polling 模块 import 清单: %s" % json.dumps(imported, ensure_ascii=False))
bad = [ln for ln in imported if "pbl_runtime_tx" in ln and "tx_env" not in ln]
check("AC4.2 polling 不 import 写入端事务模块(只依赖 sql/errors/tx_env)", not bad, str(bad))
check("AC4.3 兜底只读 pbl_runtime_event(SQL 无 INSERT/UPDATE/DELETE)",
all(k not in build_poll_sql("sqlite", "2026-01-01 00:00:00", "s")[0].upper()
for k in ("INSERT", "UPDATE", "DELETE")))
def test_facade_surface(dbpath):
section("AC5 对外门面可调用性(防漏注册导致 NameError)")
import world_sync as ws
names = ["PollingFallback", "build_poll_sql", "configure_fallback", "fallback_stats",
"get_fallback", "normalize_watermark", "plan_uses_scene_index", "poll_since",
"reset_fallback", "run_fallback", "wait_event", "explain_poll_query",
"measure_once", "run_chain", "summarize_latency", "latency_report"]
missing = [n for n in names if not hasattr(ws, n)]
print(" 检查 %d 个导出名, 缺失=%s" % (len(names), missing))
check("AC5.1 world_sync 包级导出齐全", not missing, str(missing))
r = poll_since("sc_facade", "1970-01-01 00:00:00", conn_factory=factory_for(dbpath))
# poll_since 门面的真实返回键是 count(不是 recovered_count,后者属
# PollingFallback.poll_once/run 的返回)——口径与源码/文档统一(QC #15)。
check("AC5.2 poll_since 门面可直调,返回键为 rows/count",
isinstance(r, dict) and "rows" in r and "count" in r
and "recovered_count" not in r and r["count"] == len(r["rows"]),
json.dumps({k: r[k] for k in ("scene_id", "count")}, ensure_ascii=False))
ex = explain_poll_query("sc_facade", "1970-01-01 00:00:00",
conn_factory=factory_for(dbpath))
check("AC5.3 explain_poll_query 门面可直调", isinstance(ex, list), str(len(ex)) + " rows")
st = json.dumps(__import__("world_sync").fallback_stats(), ensure_ascii=False)
check("AC5.4 fallback_stats 返回计数快照", "polls" in st, st[:120])
# ------------------------------------------------------------------ main
def main():
print("# M11b-4 自测留证")
print("# 生成时间: %s" % time.strftime("%Y-%m-%d %H:%M:%S %z"))
print("# 命令: cd modules/world_sync && .venv/bin/python scripts/m11b4_selftest.py")
print("# Python: %s" % sys.version.split()[0])
tmpdir = tempfile.mkdtemp(prefix="m11b4_")
dbpath = os.path.join(tmpdir, "m11b4.sqlite")
conn = make_db(dbpath)
conn.close()
os.environ["PBL_RUNTIME_DB_URL"] = "sqlite:///" + dbpath
scene, uid, t_created = test_broadcast_lost_recovered_within_3s(dbpath)
test_no_duplicate_delivery(dbpath, scene, uid)
test_watermark_format_guard(dbpath)
test_broadcast_received_stops_polling(dbpath)
test_explain_uses_index(dbpath)
rep = test_e2e_latency(dbpath)
test_fallback_latency_reported(dbpath)
test_no_intrusion(dbpath)
test_facade_surface(dbpath)
section("汇总")
passed = sum(1 for r in RESULTS if r["ok"])
for r in RESULTS:
print(" %-4s %s" % ("OK" if r["ok"] else "XX", r["name"]))
print("\n %d/%d 通过" % (passed, len(RESULTS)))
print(" 时延报告: P50=%.3fms P90=%.3fms P99=%.3fms max=%.3fms verdict=%s"
% (rep["p50_ms"], rep["p90_ms"], rep["p99_ms"], rep["max_ms"], rep["verdict"]))
# QC #12:报告必须自带运行时间戳,文档引用数值时标注该时间戳,
# 避免出现「另一次实跑」却无留证的表述
rep["run_at"] = time.strftime("%Y-%m-%d %H:%M:%S %z")
report_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"m11b4_latency_report.json")
with open(os.path.join(tmpdir, "m11b4_latency_report.json"), "w",
encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
# 同时落盘到 scripts/ 目录留证(可入库、可被 QC 复核)
with open(report_path, "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(" 报告 JSON 落盘: %s" % report_path)
failures = len(RESULTS) - passed
print("\n RESULT: %s FAILURES=%d" % ("PASS" if failures == 0 else "FAIL", failures))
return 0 if failures == 0 else 1
if __name__ == "__main__":
sys.exit(main())