games-development-ai/cheap-worker/tests/test_play_spec_contract.py
lili 234c076f51 feat(cheap): T1 驱动器约定收敛·C5 考卷契约接线(startRitual 起局仪式+driver 显式化+sourceHash 陈旧重生·金标 held-constant 不误杀) (W-S1 修复三单·单①)
- cheap_run:_build_play_spec 产 C5 形状(schemaVersion/derivedFrom/selectionBasis;key-cycle 带默认 startRitual);
  ensure_play_spec 以 derivedFrom.generator 区分本产线自动 spec 与手工金标——自动 spec 按 sourceHash 判陈旧重生,
  金标/旧世代 held-constant 永不覆盖;产出走 C5 best-effort 自校验(强校验在单测)
- play.cdp.cjs:NONPLAY 升模块级;runStartRitual(tap-selector 读 state 坐标/input-sequence/默认 Enter→Space→中心点,
  进游玩态即止);主流程 runDriver 前执行声明仪式(无声明零行为变化);runKeyCycle 对 MENULIKE 前置态做默认起局尝试
  (镜像 runTapTargets 扫点范式,治 fx4c 卡 menu 驱动器约定漂移)
- tests/test_play_spec_contract.py:两族过 C5 强校验/显式字段/hash 重生/金标不误杀/保守支 14 用例

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:02:53 -07:00

238 lines
9.9 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_play_spec_contract.py — C5 考卷契约接线单测(W-S1 修复三单·单①)。
守的不变量:
· 自动 spec(两族)必过 contracts/play-loop/play-spec.schema.json(C5)强校验——契约面收口。
· driver 显式化:type/selectionBasis 进考卷(不再是只活在 _build_play_spec 里的隐形开关);
key-cycle 族带 startRitual 默认起局仪式;两族都带 derivedFrom 派生绑定(sourceHash/generator)。
· 陈旧可判定:本产线自动 spec 已存在时,sourceHash 一致=复用、不一致=重生(取代旧「已存在一律不覆盖」);
手工金标/外部注入/旧世代 spec(非本产线 generator)held-constant 永不被 hash 检查覆盖(金标不误杀);
源 hash 不可算时保守不覆盖。
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_play_spec_contract.py -v
"""
import importlib.util
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
_REPO_ROOT = Path(__file__).resolve().parents[2]
_PLAYLOOP = _REPO_ROOT / "contracts" / "play-loop"
def _load_validator():
"""按路径加载 contracts/play-loop/validate.py(零依赖 stdlib 校验器,不是包)。"""
spec = importlib.util.spec_from_file_location("_playloop_validate_t", _PLAYLOOP / "validate.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
_V = _load_validator()
_C5 = json.loads((_PLAYLOOP / "play-spec.schema.json").read_text(encoding="utf-8"))
def _assert_c5(spec_dict):
errors = _V.validate(_C5, spec_dict, _C5)
assert errors == [], f"自动 spec 未过 C5 契约校验:{errors}"
# ───────────────────────── 两族产物过 C5 强校验 ─────────────────────────
def test_tap_targets_spec_passes_c5():
spec = R._build_play_spec({"phase": "play", "targets": [{"x": 1, "y": 1, "occupied": True}]},
source_hash="a" * 64)
_assert_c5(spec)
def test_key_cycle_spec_passes_c5():
spec = R._build_play_spec({"phase": "menu", "score": 0}, source_hash="b" * 64)
_assert_c5(spec)
def test_spec_without_hash_still_passes_c5():
"""hash 不可算时占位 'unavailable' 仍满足 C5 sourceHash 非空 required。"""
for state in (None, {"targets": []}):
_assert_c5(R._build_play_spec(state))
# ───────────────────────── driver 显式化(单① C5 接线)─────────────────────────
def test_driver_selection_basis_explicit():
"""驱动器族选择依据写进考卷(自解释),两族都有;不再单靠隐形键约定。"""
tap = R._build_play_spec({"targets": []})
keyc = R._build_play_spec({"score": 0})
assert "targets" in tap["driver"]["selectionBasis"]
assert "key-cycle" in keyc["driver"]["selectionBasis"]
def test_key_cycle_has_start_ritual():
"""key-cycle 族带默认起局仪式(input-sequence:Enter→Space→中心点),治卡 menu 漂移。"""
spec = R._build_play_spec({"phase": "menu"})
sr = spec["startRitual"]
assert sr["kind"] == "input-sequence"
codes = [ev.get("code") for ev in sr["inputs"] if ev["t"] == "key"]
assert codes == ["Enter", "Space"]
taps = [ev for ev in sr["inputs"] if ev["t"] == "tap"]
assert taps and taps[0]["x"] == 195 # 画布中心(390×844 竖屏中线)
def test_tap_targets_no_start_ritual():
"""tap-targets occupied 族不声明 startRitual(runTapTargets 已内建菜单扫点,声明会重复动作)。"""
spec = R._build_play_spec({"targets": []})
assert "startRitual" not in spec
def test_derived_from_binding():
"""两族都带 derivedFrom 派生绑定:sourceHash 透传、generator=本产线标识、重生语义声明。"""
spec = R._build_play_spec({"targets": []}, source_hash="deadbeef")
df = spec["derivedFrom"]
assert df["sourceHash"] == "deadbeef"
assert df["generator"] == R._SPEC_GENERATOR
assert df["regenerateOnSourceChange"] is True
# ───────────────────────── _source_hash ─────────────────────────
def test_source_hash_changes_with_src_content():
"""src/ 内容变 → hash 变;内容同 → hash 同(确定性)。目录缺失 → None。"""
with tempfile.TemporaryDirectory() as d:
orig = R._GAMES_DIR
R._GAMES_DIR = Path(d)
try:
gid = "hash-t"
src = R.game_dir(gid) / "src"
src.mkdir(parents=True)
(src / "game-logic.js").write_text("v1", encoding="utf-8")
h1 = R._source_hash(gid)
h1b = R._source_hash(gid)
(src / "game-logic.js").write_text("v2", encoding="utf-8")
h2 = R._source_hash(gid)
assert h1 == h1b and h1 != h2
assert R._source_hash("no-such-game") is None
finally:
R._GAMES_DIR = orig
# ───────────────────────── ensure_play_spec 陈旧重生 + 金标不误杀 ─────────────────────────
class _SpecEnv:
"""把 cheap_run 的 games 根与 staged 根都指到临时目录(测试隔离)。"""
def __enter__(self):
self._d = tempfile.TemporaryDirectory()
self._orig = (R._GAMES_DIR, R._WG1_DIR)
root = Path(self._d.name)
R._GAMES_DIR = root / "games"
R._WG1_DIR = root / "wg1"
return self
def __exit__(self, *a):
R._GAMES_DIR, R._WG1_DIR = self._orig
self._d.cleanup()
@staticmethod
def write_src(gid, content):
src = R.game_dir(gid) / "src"
src.mkdir(parents=True, exist_ok=True)
(src / "game-logic.js").write_text(content, encoding="utf-8")
def _spec_on_disk(gid):
return json.loads((R.wg1_game_dir(gid) / "play-spec.json").read_text(encoding="utf-8"))
def test_ensure_regenerates_on_source_change():
"""本产线自动 spec + 源工程已变(hash 不一致)→ 重生(封「拿旧考卷判新工程」)。"""
with _SpecEnv() as env:
gid = "regen-t"
env.write_src(gid, "v1")
r1 = R.ensure_play_spec(gid, {"score": 0})
assert r1["wrote"] is True
h1 = _spec_on_disk(gid)["derivedFrom"]["sourceHash"]
env.write_src(gid, "v2 changed") # 续修轮 agent 改了源
r2 = R.ensure_play_spec(gid, {"score": 0})
assert r2["wrote"] is True, f"源变后应重生,实得 {r2}"
h2 = _spec_on_disk(gid)["derivedFrom"]["sourceHash"]
assert h1 != h2
def test_ensure_reuses_when_hash_unchanged():
"""本产线自动 spec + 源未变(hash 一致)→ 复用不覆盖(不做无谓重写)。"""
with _SpecEnv() as env:
gid = "reuse-t"
env.write_src(gid, "same")
assert R.ensure_play_spec(gid, {"score": 0})["wrote"] is True
r2 = R.ensure_play_spec(gid, {"score": 0})
assert r2["wrote"] is False and "一致" in r2["reason"]
def test_ensure_never_overwrites_golden_spec():
"""金标不误杀:手工金标考卷(无本产线 generator)已存在 → held-constant,源怎么变都不覆盖。"""
with _SpecEnv() as env:
gid = "golden-t"
env.write_src(gid, "v1")
gdir = R.wg1_game_dir(gid)
gdir.mkdir(parents=True, exist_ok=True)
golden = {"driver": {"type": "tap-targets", "targetsPath": "targets"}, "_golden": True}
(gdir / "play-spec.json").write_text(json.dumps(golden), encoding="utf-8")
r = R.ensure_play_spec(gid, {"score": 0})
assert r["wrote"] is False and "held-constant" in r["reason"]
env.write_src(gid, "v2") # 源变了也不许覆盖金标
r2 = R.ensure_play_spec(gid, {"score": 0})
assert r2["wrote"] is False
assert _spec_on_disk(gid)["_golden"] is True
def test_ensure_keeps_legacy_spec_without_binding():
"""旧世代自动 spec(无 derivedFrom)落保守支:不覆盖(与既有 test_ensure_play_spec 不覆盖用例同口径)。"""
with _SpecEnv() as env:
gid = "legacy-t"
env.write_src(gid, "v1")
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, {"score": 0})
assert r["wrote"] is False
assert _spec_on_disk(gid)["existing"] is True
def test_ensure_conservative_when_hash_unavailable():
"""源 hash 不可算(src/ 缺失)+ 已有本产线自动 spec → 保守不覆盖(判不了陈旧就不动)。"""
with _SpecEnv() as env:
gid = "nohash-t"
env.write_src(gid, "v1")
assert R.ensure_play_spec(gid, {"score": 0})["wrote"] is True
# 删掉 src(hash 不可算),spec 已存在 → 不覆盖
import shutil
shutil.rmtree(R.game_dir(gid) / "src")
r = R.ensure_play_spec(gid, {"score": 0})
assert r["wrote"] is False and "不可算" in r["reason"]
def test_ensure_written_spec_passes_c5_on_disk():
"""落盘的自动 spec(经完整 ensure_play_spec 路径)也过 C5(防「纯函数过、落盘路径夹带脏字段」)。"""
with _SpecEnv() as env:
gid = "disk-c5-t"
env.write_src(gid, "v1")
assert R.ensure_play_spec(gid, {"targets": [{"occupied": True}]})["wrote"] is True
_assert_c5(_spec_on_disk(gid))
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)