- 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>
238 lines
9.9 KiB
Python
238 lines
9.9 KiB
Python
"""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)
|