games-development-ai/cheap-worker/tests/test_a11_m3_deterministic.py
lili cbfd4d871b
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(acceptance): 闭合 playtest v3 与 A+ 可信消费链
固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。

将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。

同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
2026-07-28 20:16:13 -07:00

412 lines
22 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.

"""test_a11_m3_deterministic.py — A11 切片三 M3「确定性类纠错执行」单测(execution 段零 LLM)。
守的不变量:
· apply_deterministic_modify(纯函数):config 改 core.js 一处常量 / asset 改 assets.js 一处资产键 /
多常量一次改 / 字符串·数组值 / 多行常量(Object.freeze)/ **no-op 检测**(目标找不到 → found=false、ok=false,
断言①地基)/ 坏输入容错不抛。
· materialize_source_project:base 源全 files(含 L1 plumbing)系统级落盘到 game_dir(绕 write_file 的 L1 守卫)。
· worker 接线:modifyMode=deterministic 走确定性执行(manifest 附进 trace、成功带新版 sourceProject);
regenerate-module → 显式 failed(不静默吞);create 路(无 modifyMode)零副作用、不调 modify_fn。
跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_a11_m3_deterministic.py
"""
import hashlib
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_run # noqa: E402
import cheap_modify as M # noqa: E402
import worker_service as W # noqa: E402
import dedup # noqa: E402
import pytest # noqa: E402
# game_dir 与 dedup 登记表在本模块测试期间重定向到临时目录:materialize、worker 接线、create 路(D9 dedup)
# 的落盘都进临时目录,不碰真 games/ 与真登记表(cheap_modify、worker_service 与本模块同引用一个 cheap_run
# 模块对象,改模块属性即对三者同时生效)。重定向收进 autouse fixture:只在本模块测试期间生效、跑完即恢复
# 原值,不再像早先那样在 import 时无恢复地替换 cheap_run.game_dir——那样会污染同会话后续的 test_run /
# test_studio_smoke / test_toolkit(它们按真 games/ 根校验 write 边界,game_dir 被换后一写就判越界)。
def _install_tmp_redirect():
"""把 cheap_run.game_dir、dedup.DEDUP_REGISTRY 指向新建临时目录,返回二者原值供恢复。"""
orig = (cheap_run.game_dir, dedup.DEDUP_REGISTRY)
tmp_games = Path(tempfile.mkdtemp(prefix="a11-m3-games-"))
cheap_run.game_dir = lambda gid: tmp_games / f"amgen-{gid}" # noqa: E731
dedup.DEDUP_REGISTRY = Path(tempfile.mkdtemp(prefix="a11m3-dedup-")) / "_dedup_registry.jsonl"
return orig
@pytest.fixture(autouse=True, scope="module")
def _tmp_redirect():
orig_game_dir, orig_registry = _install_tmp_redirect()
try:
yield
finally:
cheap_run.game_dir = orig_game_dir
dedup.DEDUP_REGISTRY = orig_registry
_GOOD_BUNDLE = "var __GameBundle=(function(){return{bootGameHost(){}}})();"
# ───────────────────────── ① apply_deterministic_modify ─────────────────────────
def test_config_change_round_ms():
"""config:ROUND_MS 30000→20000,core.js 那一处变、别处不动、manifest 命中、ok=true。"""
sp = {"files": {"src/core.js": "// core\nexport const ROUND_MS = 30000;\nexport const PAD = 4;\n"}}
patch = {"target": {"kind": "config", "path": "core.js#ROUND_MS"}, "payload": {"value": {"ROUND_MS": 20000}}}
out = M.apply_deterministic_modify(sp, patch)
assert out["ok"] is True, out
assert "export const ROUND_MS = 20000;" in out["files"]["src/core.js"]
assert "export const PAD = 4;" in out["files"]["src/core.js"], "别的常量不应被动"
rec = out["manifest"][0]
assert rec["found"] is True and rec["key"] == "ROUND_MS"
assert rec["old"] == "30000" and rec["new"] == "20000"
assert rec["file"].endswith("core.js") and rec["kind"] == "config"
def test_asset_change_image_file():
"""asset:assets.js IMAGE_FILES 的 heroSprite 换文件名,manifest 命中、别的资产不动。"""
sp = {"files": {"src/assets.js": "export const IMAGE_FILES = { heroSprite: 'hero.png', bg: 'bg.jpg' };\n"}}
patch = {"target": {"kind": "asset", "path": "assets.js#heroSprite"},
"payload": {"value": {"heroSprite": "hero-green.png"}}}
out = M.apply_deterministic_modify(sp, patch)
assert out["ok"] is True, out
assert "hero-green.png" in out["files"]["src/assets.js"]
assert "bg.jpg" in out["files"]["src/assets.js"], "别的资产键不应被动"
rec = out["manifest"][0]
assert rec["found"] is True and rec["old"] == "hero.png" and rec["new"] == "hero-green.png"
assert rec["kind"] == "asset"
def test_asset_base_url_change():
"""asset:baseUrl 特例 → 换 loadAssets 默认资产根。"""
sp = {"files": {"src/assets.js": "const root = baseUrl == null ? './assets/' : baseUrl;\n"}}
patch = {"target": {"kind": "asset"}, "payload": {"value": {"baseUrl": "https://cdn.x/g/"}}}
out = M.apply_deterministic_modify(sp, patch)
assert out["ok"] is True, out
assert "https://cdn.x/g/" in out["files"]["src/assets.js"]
assert out["manifest"][0]["old"] == "./assets/"
def test_noop_const_not_found():
"""no-op(断言①地基):目标常量不存在 → found=false、整体 ok=false、带 error。"""
sp = {"files": {"src/core.js": "export const ROUND_MS = 30000;\n"}}
patch = {"target": {"kind": "config"}, "payload": {"value": {"NOT_THERE": 5}}}
out = M.apply_deterministic_modify(sp, patch)
assert out["ok"] is False, "no-op 不应算真改动"
assert out["manifest"][0]["found"] is False
assert out["error"]
# 文本未被改动。
assert out["files"]["src/core.js"] == "export const ROUND_MS = 30000;\n"
def test_noop_same_value():
"""no-op:新值与旧值相同(常量命中但无变化)→ ok=false。"""
sp = {"files": {"src/core.js": "export const SPEED = 5;\n"}}
patch = {"target": {"kind": "config"}, "payload": {"value": {"SPEED": 5}}}
out = M.apply_deterministic_modify(sp, patch)
assert out["manifest"][0]["found"] is True, "常量命中"
assert out["ok"] is False, "命中但值未变 = no-op"
def test_string_array_and_multi_const():
"""字符串值 / 数组值 / 多常量一次改 —— 都支持,manifest 三条全命中。"""
sp = {"files": {"src/core.js": (
"export const SPEED = 5;\n"
"export const LABEL = 'old';\n"
"export const KEYS = ['a','b'];\n"
)}}
patch = {"target": {"kind": "config"},
"payload": {"value": {"SPEED": 8, "LABEL": "new", "KEYS": ["x", "y", "z"]}}}
out = M.apply_deterministic_modify(sp, patch)
assert out["ok"] is True, out
txt = out["files"]["src/core.js"]
assert "export const SPEED = 8;" in txt
assert 'export const LABEL = "new";' in txt, "字符串值 → JS 字符串字面量(json 双引号合法)"
assert 'export const KEYS = ["x", "y", "z"];' in txt, "数组值 → JS 数组字面量"
assert len(out["manifest"]) == 3 and all(r["found"] for r in out["manifest"])
def test_config_searches_other_l3_when_not_in_core():
"""config 常量不在 core.js 时,按优先序搜其它 L3(game-logic.js)并命中。"""
sp = {"files": {
"src/core.js": "export const A = 1;\n",
"src/game-logic.js": "export const SPAWN_RATE = 2;\n",
}}
patch = {"target": {"kind": "config"}, "payload": {"value": {"SPAWN_RATE": 9}}}
out = M.apply_deterministic_modify(sp, patch)
assert out["ok"] is True, out
assert "export const SPAWN_RATE = 9;" in out["files"]["src/game-logic.js"]
assert out["manifest"][0]["file"].endswith("game-logic.js")
def test_multiline_const_replace():
"""多行常量(Object.freeze 跨行)→ 多行兜底正则替换,且不吞掉其后的常量。"""
sp = {"files": {"src/core.js": (
"export const PALETTE = Object.freeze([\n"
" { fill: '#fff', score: 1 },\n"
" { fill: '#000', score: 2 },\n"
"]);\n"
"export const NEXT = 7;\n"
)}}
patch = {"target": {"kind": "config"}, "payload": {"value": {"PALETTE": [1, 2, 3]}}}
out = M.apply_deterministic_modify(sp, patch)
assert out["ok"] is True, out
txt = out["files"]["src/core.js"]
assert "export const PALETTE = [1, 2, 3];" in txt
assert "export const NEXT = 7;" in txt, "多行替换必须锚到本常量行尾分号,不能吞掉后续常量"
def test_level_complex_layout_unsupported():
"""level:复杂布局重排(value 非 {常量名:新值})→ unsupported、ok=false。"""
sp = {"files": {"src/core.js": "export const X = 1;\n"}}
out = M.apply_deterministic_modify(sp, {"target": {"kind": "level"}, "payload": {"layout": [[1, 0], [0, 1]]}})
assert out["ok"] is False and "level" in (out["error"] or "")
def test_level_const_text_replace_supported():
"""level:关卡常量文本替换(payload.value={常量名:新值})与 config 同路,支持。"""
sp = {"files": {"src/core.js": "export const LEVEL_ROWS = 8;\n"}}
out = M.apply_deterministic_modify(sp, {"target": {"kind": "level"}, "payload": {"value": {"LEVEL_ROWS": 12}}})
assert out["ok"] is True
assert "export const LEVEL_ROWS = 12;" in out["files"]["src/core.js"]
assert out["manifest"][0]["kind"] == "level"
def test_tolerant_bad_inputs():
"""坏 source_project / 坏 patch / 非确定性 kind —— 容错返 ok=false,绝不抛。"""
assert M.apply_deterministic_modify("{not json", {"target": {"kind": "config"},
"payload": {"value": {"X": 1}}})["ok"] is False
assert M.apply_deterministic_modify(None, {})["ok"] is False
assert M.apply_deterministic_modify({"files": {"src/core.js": "export const X = 1;\n"}}, "{bad")["ok"] is False
assert M.apply_deterministic_modify({"files": {"src/core.js": "x"}}, None)["ok"] is False
out = M.apply_deterministic_modify({"files": {"src/core.js": "x"}},
{"target": {"kind": "behavior"}, "payload": {"value": {"X": 1}}})
assert out["ok"] is False and "behavior" in (out["error"] or "")
def test_apply_does_not_mutate_input():
"""apply 不原地改入参 files(返回的是改后拷贝)。"""
sp = {"files": {"src/core.js": "export const A = 1;\n"}}
M.apply_deterministic_modify(sp, {"target": {"kind": "config"}, "payload": {"value": {"A": 9}}})
assert sp["files"]["src/core.js"] == "export const A = 1;\n", "入参 files 不应被原地改动"
# ───────────────────────── ② materialize_source_project ─────────────────────────
def test_materialize_writes_all_files_incl_l1():
"""全 files(含 L1 plumbing host-config/game/index/entry)落盘到 game_dir(系统落盘绕 L1 守卫)。"""
files = {
"src/core.js": "export const A = 1;\n",
"src/host-config.js": "// L1 plumbing host-config\n",
"src/game.js": "// L1 wrapper game\n",
"index.html": "<html><body>g</body></html>",
"entry-bundle.js": "// L1 entry\n",
}
gd = M.materialize_source_project("mat1", files)
assert (gd / "src" / "core.js").read_text(encoding="utf-8") == "export const A = 1;\n"
assert (gd / "src" / "host-config.js").exists(), "L1 host-config 也应落盘(系统落盘不拦 L1)"
assert (gd / "src" / "game.js").exists(), "L1 game.js 也应落盘"
assert (gd / "index.html").read_text(encoding="utf-8") == "<html><body>g</body></html>"
assert (gd / "entry-bundle.js").exists(), "L1 entry-bundle 也应落盘"
def test_materialize_skips_escape_path():
"""越界路径(../ 逃逸)被跳过、不写到 game_dir 外,且不抛。"""
gd = M.materialize_source_project("mat2", {"../escape.js": "x", "src/ok.js": "y"})
assert (gd / "src" / "ok.js").exists()
assert not (gd.parent / "escape.js").exists(), "越界路径必须被跳过"
def test_deterministic_v3_reject_uses_one_restricted_writer(monkeypatch):
"""A11 确定性改首轮 v3 verified reject 后只调用一次受限 writer,并以复验结果收口。"""
import cheap_verify
gid = "det-v3-repair"
gd = cheap_run.game_dir(gid)
gd.mkdir(parents=True, exist_ok=True)
(gd / "entry-bundle.js").write_text("// entry", encoding="utf-8")
origin_brief = "一个数字华容道解谜"
source = {"scaffoldTemplate": "_template-puzzle",
"originBrief": origin_brief,
"originBriefHash": hashlib.sha256(origin_brief.encode("utf-8")).hexdigest(),
"files": {"src/core.js": "export const ROUND_MS = 30000;\n"}}
patch = {"target": {"kind": "config"}, "payload": {"value": {"ROUND_MS": 20000}}}
monkeypatch.setattr(cheap_run, "build", lambda game_id: {"ok": True, "output": ""})
monkeypatch.setattr(cheap_run, "stage", lambda game_id: {"ok": True, "output": ""})
monkeypatch.setattr(cheap_run, "smoke", lambda *a, **k: {"ok": True})
verdict = {"pass": False, "guards": {name: {"pass": True} for name in cheap_verify._FLOOR_GATES}}
monkeypatch.setattr(cheap_run, "play", lambda *a, **k: {"ok": True, "verdict": verdict})
monkeypatch.setattr(cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v3"})
async def _reject(req):
return {"runId": "a11-r1", "floor": {"pass": True},
"artifactHash": "a" * 64,
"acceptanceRequestHash": req["acceptanceIdentity"]["acceptanceRequestHash"],
"decision": {"outcome": "reject", "accepted": False, "publishFrozen": True,
"repairEligible": True, "repairFeedback": "修闭环", "parentChainCostRmb": 0.2},
"compatibility": {"accepted": False, "ok": False, "acceptanceVersion": "v3", "trace": {}}}
monkeypatch.setattr(cheap_verify, "run_acceptance_v3", _reject)
monkeypatch.setattr(cheap_verify, "is_v3_repair_authorized",
lambda payload: (payload.get("decision") or {}).get("repairEligible") is True)
monkeypatch.setattr(cheap_verify, "is_v3_publishable",
lambda payload: (payload.get("decision") or {}).get("outcome") == "accept")
repair_calls = []
def _repair(game_id, intent, **kwargs):
repair_calls.append((game_id, intent, kwargs))
return {"accepted": True, "acceptanceVersion": "v3", "verdictFull": verdict,
"acceptanceV3": {"runId": "a11-r2", "decision": {"outcome": "accept", "accepted": True}}}
monkeypatch.setattr(M, "_default_rewrite", _repair)
out = M.execute_deterministic_modify(
gid, source, patch, base_version_id="base-1", task_trace_id="trace-det-v3-repair")
assert out["status"] == "succeeded" and len(repair_calls) == 1
_, intent, kwargs = repair_calls[0]
assert intent == "修闭环" and kwargs["acceptance_repair_count"] == 1
assert kwargs["acceptance_parent_run_id"] == "a11-r1"
assert kwargs["acceptance_brief"] == "一个数字华容道解谜", "复验沿用原游戏 brief,不得换成修复反馈"
assert kwargs["genre"] == "puzzle"
acceptance = out["acceptance"]
assert acceptance["acceptanceV3FirstPass"]["runId"] == "a11-r1"
assert acceptance["repairAttempted"] is True and acceptance["acceptedAfterRepair"] is True
def test_a11_origin_brief_requires_nonempty_self_consistent_source_lineage():
"""fresh worker 只能从 SourceProject 恢复原题面;缺失、空值和伪 hash 均 fail-closed。"""
brief = "原始解谜题面"
valid = {"originBrief": brief,
"originBriefHash": hashlib.sha256(brief.encode("utf-8")).hexdigest()}
assert M._resolve_a11_origin_brief(valid) == (brief, valid["originBriefHash"])
assert M._resolve_a11_origin_brief({}) == (None, None)
assert M._resolve_a11_origin_brief({"originBrief": "", "originBriefHash": hashlib.sha256(b"").hexdigest()}) == (None, None)
assert M._resolve_a11_origin_brief({"originBrief": brief, "originBriefHash": "a" * 64}) == (None, None)
# ───────────────────────── ④ worker 接线 ─────────────────────────
def test_worker_deterministic_route_attaches_manifest_on_failed():
"""modifyMode=deterministic → 走确定性执行;失败(no-op)路也把 manifest/error 附进 trace 回调。"""
captured = {}
fake = {"status": "failed",
"manifest": [{"file": "src/core.js", "kind": "config", "key": "X", "old": None, "new": "5", "found": False}],
"verdict": None, "error": "no-op:常量未找到", "stage": "apply"}
state = W.WorkerState(modify_fn=lambda gid, sp, patch, **kw: fake,
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: None)
job = {"job_id": "m1", "traceId": "m1", "modifyMode": "deterministic", "gameId": "nope-x",
"sourceProject": "{}", "modifyPatch": "{}", "brief": "x", "callback": {"target": "http://cb"}}
W.process_job(state, job)
p = captured["payload"]
assert p["status"] == "failed", "无 bundle + verdict 失败 → failed"
assert p["trace"]["modifyManifest"][0]["key"] == "X"
assert p["trace"]["modifyError"] == "no-op:常量未找到"
def test_worker_deterministic_route_success_carries_bundle_and_source():
"""deterministic 成功 → result-out 带 engineBundle(重建产物)+ manifest + 新版 sourceProject(链式改)。"""
gid = "m3ok"
gd = cheap_run.game_dir(gid)
(gd / "src").mkdir(parents=True, exist_ok=True)
(gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8")
(gd / "src" / "core.js").write_text("export const A = 1;\n", encoding="utf-8")
captured = {}
fake = {"status": "succeeded",
"manifest": [{"file": "src/core.js", "kind": "config", "key": "ROUND_MS",
"old": "30000", "new": "20000", "found": True}],
# 真实 verdict.guards 带完整四门(W-AXIS-V2 预筛权威 = 四门投影;build_result_out 据它算 status)。
"verdict": {"pass": True, "guards": {"A_boot": {"pass": True}, "B_uncaught": {"pass": True},
"C_frame": {"pass": True}, "D_render": {"pass": True}}},
"error": None, "stage": "play"}
profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"}
state = W.WorkerState(modify_fn=lambda *a, **kw: fake,
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: profile)
job = {"job_id": "m2", "traceId": "m2", "modifyMode": "deterministic", "gameId": gid,
"sourceProject": '{"scaffoldTemplate":"_template-puzzle"}',
"modifyPatch": "{}", "brief": "点点乐", "callback": {"target": "http://cb"}}
W.process_job(state, job)
p = captured["payload"]
assert p["status"] == "succeeded"
assert "__GameBundle" in p["engineBundle"], "成功路携重建后的 bundle"
assert p["trace"]["modifyManifest"][0]["key"] == "ROUND_MS"
assert "sourceProject" in p, "成功路 best-effort 带新版源(供下一次链式改)"
assert json.loads(p["sourceProject"])["scaffoldTemplate"] == "_template-puzzle"
def test_worker_modify_summary_preserves_v3_sealed_identity():
"""deterministic 包装层不得丢 acceptanceV3,否则 result_out 会错误回落 legacy。"""
sealed = {
"runId": "run-1", "artifactHash": "a" * 64, "briefHash": "b" * 64,
"acceptanceRequestHash": "c" * 64, "floor": {"pass": True},
"decision": {"outcome": "accept", "accepted": True, "publishFrozen": False},
"compatibility": {"accepted": True, "ok": True, "acceptanceVersion": "v3", "trace": {}},
}
result = {"status": "succeeded", "stage": "play", "verdict": None,
"acceptance": {"acceptanceV3": sealed}}
summary = W._modify_summary(result, "g1")
assert summary["acceptanceV3"] is sealed
assert summary["acceptanceRunId"] == "run-1"
assert summary["acceptanceArtifactHash"] == "a" * 64
assert summary["acceptanceBriefHash"] == "b" * 64
assert summary["acceptanceRequestHash"] == "c" * 64
def test_worker_regenerate_module_routes_to_executor_no_llm():
"""modifyMode=regenerate-module(M4 已实现)→ 走 execute_regenerate_modify、不再占位 failed。
无 sourceProject/modifyPatch → intent 空,executor 早失败(零 LLM、不 scaffold/不重写)→ 仍发 failed 回调
(任务不挂 RUNNING)+ 失败原因进 trace(不静默吞)。验 regen 分流已接真执行器。完整 regen 单测见 test_a11_m4。
"""
captured = {}
state = W.WorkerState(send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: None) # 默认 regen_fn → 真 execute_regenerate_modify(intent 空零 LLM)
job = {"job_id": "rm1", "traceId": "rm1", "modifyMode": "regenerate-module", "gameId": "g",
"brief": "x", "callback": {"target": "http://cb"}} # 无 sourceProject/modifyPatch
W.process_job(state, job)
assert captured["payload"]["status"] == "failed", "intent 空 → failed 回调(任务不挂 RUNNING)"
assert captured["payload"]["trace"]["modifyError"], "失败原因附进 trace(不静默吞)"
def _boom_modify(*a):
raise AssertionError("create 路不应调用 modify_fn")
def test_create_path_unaffected_by_modify_wiring():
"""无 modifyMode 的 create 路:走 run_fn、不触 modify_fn(modify 接线对 create 零副作用)。"""
gid = "create1"
gd = cheap_run.game_dir(gid)
(gd / "src").mkdir(parents=True, exist_ok=True)
(gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8")
called = {}
state = W.WorkerState(
run_fn=lambda job: (called.update(ran=True) or {"verdict": {"pass": True, "guards": {
"A_boot": {"pass": True}, "B_uncaught": {"pass": True},
"C_frame": {"pass": True}, "D_render": {"pass": True}}}}, gd),
send_fn=lambda u, p, s: called.update(payload=p) or (200, ""),
profile_fn=lambda j, g: None,
modify_fn=_boom_modify,
)
job = {"job_id": "c1", "traceId": "c1", "brief": "x", "callback": {"target": "http://cb"}} # 无 modifyMode
W.process_job(state, job)
assert called.get("ran") is True, "create 路必须走 run_fn"
assert called["payload"]["status"] == "succeeded"
if __name__ == "__main__":
_install_tmp_redirect() # 独立运行(非 pytest,autouse fixture 不触发):单进程一次性装重定向,退出即释放、无需恢复
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
_failed = 0
for _fn in _fns:
try:
_fn()
print(f" PASS {_fn.__name__}")
except Exception as e: # noqa: BLE001
_failed += 1
print(f" FAIL {_fn.__name__}: {type(e).__name__}: {e}")
print(f"\n[test_a11_m3_deterministic] {len(_fns) - _failed}/{len(_fns)} passed")
sys.exit(1 if _failed else 0)