games-development-ai/cheap-worker/tests/test_ensure_play_spec.py
lili ffe3a5c993 fix(WU-C 5.4): tap-targets harness 认单数/可空 target — 解 M2 Node 退役阻塞
M2 auto-vs-golden 门抓到的生成契约漂移修复(Codex 实现 + 主代理诊断/受控验证)。
根因:~20% click-score 款 _forensicsView 暴露单数 target(对象、菜单态为 null)
而非复数 targets(数组),tap-targets harness 只认复数数组:① _build_play_spec
推断漏判→误推 key-cycle;② 驱动器 targetsPath 找不到单数 target。

两处修(认两种合法形态、既有数组行为零变):
- cheap_run.py:_build_play_spec 以【键存在性】判 tap-targets(targets/target 键
  在即可,菜单态 null/空数组仍归 tap-targets)——非值类型(空数组过/null 不过的
  非对称是漏判根因)。
- play.cdp.cjs:runTapTargets 取值非数组时回退读单数 target、包成 1 元素数组。

验证:退化款 avg-click-score-14 auto 路 E_live/G_input/H_progress 0.667→1.0
(3/3 确定性复现);test_ensure_play_spec 8→13 绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:41:16 -07:00

156 lines
7.7 KiB
Python

"""
test_ensure_play_spec.py — 便宜档自动 play-spec 加厚到金标同质单测(M1 U2)。
守的不变量:
· tap-targets occupied 族:自动 spec 补 expectedEngineCallPrefixes,且与三份金标 fixtures 逐字段一致(同质)。
· 防自证:expectedEngineCallPrefixes 是【外生预置常量】,不随该局 state 实际内容(targets/score)变——
绝不从实际调用反推,否则 F_wiring 退化自证、必过、假绿(gen-path-parity skill 铁律)。
· key-cycle 族:薄版,不产 expectedEngineCallPrefixes(待输入键契约,WU-C 5.4)。
· ensure_play_spec 已存在不覆盖(不踩既有金标 fixtures)。
跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_ensure_play_spec.py
"""
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 as R # noqa: E402
_GOLDEN_PREFIXES = ["sessionScore.", "audioMusic.", "juice."]
_GOLDEN_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "golden-specs"
# ───────────────────────── tap-targets 加厚到金标同质 ─────────────────────────
def test_tap_targets_adds_golden_prefixes():
spec = R._build_play_spec({"phase": "play", "score": 5, "targets": [{"x": 1, "y": 1, "occupied": True}]})
assert spec["driver"]["type"] == "tap-targets"
assert spec["expectedEngineCallPrefixes"] == _GOLDEN_PREFIXES
def test_singular_target_object_uses_tap_targets():
"""单数 target 对象是合法点击目标形态,应走 tap-targets 驱动。"""
spec = R._build_play_spec({"target": {"x": 1, "y": 2, "r": 3, "occupied": True}, "phase": "play"})
assert spec["driver"]["type"] == "tap-targets"
def test_singular_target_null_menu_state_uses_tap_targets():
"""菜单态 target 可为 null,但暴露 target 键即声明点击目标类。"""
spec = R._build_play_spec({"target": None, "phase": "menu", "score": 0})
assert spec["driver"]["type"] == "tap-targets"
def test_targets_array_uses_tap_targets():
"""复数 targets 数组保持既有 tap-targets 驱动行为。"""
spec = R._build_play_spec({"targets": [{"x": 1, "y": 2, "r": 3, "occupied": True}], "phase": "play"})
assert spec["driver"]["type"] == "tap-targets"
def test_empty_targets_array_still_uses_tap_targets():
"""空 targets 数组仍代表点击类导出形态,不可退回 key-cycle。"""
spec = R._build_play_spec({"targets": [], "phase": "play"})
assert spec["driver"]["type"] == "tap-targets"
def test_matches_golden_fixtures():
"""加厚后的 prefixes 与三份金标 fixtures 的 expectedEngineCallPrefixes 逐字段一致(同质)。"""
spec = R._build_play_spec({"targets": []}) # targets=[] 仍是 list → tap-targets
for fn in ("whack-mole", "click-score", "shop-serve"):
golden = json.loads((_GOLDEN_DIR / f"{fn}.play-spec.json").read_text(encoding="utf-8"))
assert spec["expectedEngineCallPrefixes"] == golden["expectedEngineCallPrefixes"], f"{fn} 前缀与金标不一致"
def test_assertions_match_golden():
"""driver 形态 + assertAfterPlay(op/path)+ expectLatch 与金标一致(tap-targets occupied 族同质核心)。"""
spec = R._build_play_spec({"targets": [{"occupied": True}]})
golden = json.loads((_GOLDEN_DIR / "whack-mole.play-spec.json").read_text(encoding="utf-8"))
assert spec["driver"]["type"] == golden["driver"]["type"] == "tap-targets"
assert spec["driver"]["targetMode"] == golden["driver"]["targetMode"] == "occupied"
assert spec["assertAfterPlay"][0]["path"] == golden["assertAfterPlay"][0]["path"] == "score"
assert spec["assertAfterPlay"][0]["op"] == golden["assertAfterPlay"][0]["op"] == "increased"
assert spec["expectLatch"] == golden["expectLatch"] is True
# ───────────────────────── 防自证:期望基准外生 ─────────────────────────
def test_prefixes_are_external_constant_not_derived():
"""防自证核心:两个 targets 内容/score 完全不同的局 → expectedEngineCallPrefixes 恒等(外生常量,不反推实际)。"""
s1 = {"phase": "play", "score": 1, "targets": [{"x": 1, "y": 1, "occupied": True}]}
s2 = {"phase": "play", "score": 888, "targets": [{"x": 9, "y": 9, "occupied": False},
{"x": 3, "y": 2, "occupied": True}]}
p1 = R._build_play_spec(s1)["expectedEngineCallPrefixes"]
p2 = R._build_play_spec(s2)["expectedEngineCallPrefixes"]
assert p1 == p2 == _GOLDEN_PREFIXES, "期望前缀必须外生恒定,不随该局 state 实际内容变(防 F_wiring 自证)"
# ───────────────────────── key-cycle 薄版 ─────────────────────────
def test_key_cycle_thin_no_prefixes():
spec = R._build_play_spec({"phase": "play", "score": 0}) # 无 targets → key-cycle
assert spec["driver"]["type"] == "key-cycle"
assert "expectedEngineCallPrefixes" not in spec, "key-cycle 薄版不应加厚 prefixes(待输入键契约)"
assert "WU-C 5.4" in spec.get("_note", ""), "key-cycle 薄版应标注延后原因"
def test_state_without_target_keys_is_key_cycle_thin():
"""没有 targets/target 键时保持按键类薄版回退。"""
spec = R._build_play_spec({"phase": "play", "score": 0})
assert spec["driver"]["type"] == "key-cycle"
def test_none_state_is_key_cycle_thin():
spec = R._build_play_spec(None) # None → 保守按键类
assert spec["driver"]["type"] == "key-cycle"
assert "expectedEngineCallPrefixes" not in spec
# ───────────────────────── ensure_play_spec 已存在不覆盖 ─────────────────────────
def test_ensure_play_spec_no_overwrite():
"""已存在不覆盖:保护既有(金标)fixtures 不被自动 spec 踩掉。"""
with tempfile.TemporaryDirectory() as d:
orig = R._WG1_DIR
R._WG1_DIR = Path(d)
try:
gid = "test-overwrite"
gdir = R.wg1_game_dir(gid)
gdir.mkdir(parents=True, exist_ok=True)
(gdir / "play-spec.json").write_text('{"existing": true}', encoding="utf-8")
r = R.ensure_play_spec(gid, {"targets": [{"occupied": True}]})
assert r["wrote"] is False and "不覆盖" in r["reason"]
assert json.loads((gdir / "play-spec.json").read_text(encoding="utf-8"))["existing"] is True
finally:
R._WG1_DIR = orig
def test_ensure_play_spec_writes_tap_targets_with_prefixes():
"""正常写:tap-targets 落盘的 spec 含金标同质 prefixes。"""
with tempfile.TemporaryDirectory() as d:
orig = R._WG1_DIR
R._WG1_DIR = Path(d)
try:
gid = "test-write"
r = R.ensure_play_spec(gid, {"phase": "play", "score": 1, "targets": [{"occupied": True}]})
assert r["wrote"] is True and r["driverType"] == "tap-targets"
spec = json.loads((R.wg1_game_dir(gid) / "play-spec.json").read_text(encoding="utf-8"))
assert spec["expectedEngineCallPrefixes"] == _GOLDEN_PREFIXES
finally:
R._WG1_DIR = orig
if __name__ == "__main__":
_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{len(_fns) - _failed}/{len(_fns)} passed")
sys.exit(1 if _failed else 0)