games-development-ai/cheap-worker/tests/test_genre_puzzle_assets.py
lili 8d851ea716
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(cheap-gen): W-AXIS-V2 波2——取证契约退役+提示全加载链清洗,四门∧测试员为唯一验收链
opus 执行位交付、主会话四步验收过(三套测试亲跑/红线 diff 亲读/冒烟截图亲眼):

- 验收消费:check 新增源码级插件调用存在断言(_pluginCallPresenceErrors 词法层,
  接替 F_wiring 真接线护栏);_forensicsView「必须实现」拦截摘除,降级为可选调试
  接口(handleTap 输入契约与 ctx.log 日志纪律保留);gate_judge 放行措辞对齐四门
- play-spec/驱动器退役(逐件裁定 10 件):gen.mjs done 门自动产 play-spec 摘除、
  cheap_gates/cheap_studio/cheap_modify/service_driver 调用摘除、_read_driver_type
  改读 evidence/playtest/playtest.json;golden-spec 三工具(auto_vs_golden/bake_off/
  compare_node)显式封存;ensure_play_spec 两实现保留一个版本窗口(只摘调用不删码)
- prompt 全加载链清洗:cheap-system.md v1.6.3→1.7.0(删 _forensicsView 自动验收
  契约红线与全部 targets/occupied 语义,五法句/品类指路同步),cheap_roles 内置
  回退逐字节同步(test_roles 12/12 证 parity),modify 切片经运行时切块自动同步;
  5 品类 skills+littlejs 手册+recipes×2+模板 8 件+README 6 件教学清洗,真设计
  价值(单击 casual/可解性质/双层反馈/latch)保留
- 批账:hard_genre_batch/xtheme 行加 playtest 段,_row_total_cost 修 shadow 下
  测试员成本漏算(v2 取 max 防双计)

验收(主会话亲验):pytest cheap-worker 512 绿 + tier2 141 绿 + node 49/49;
25 局基线新旧口径重放对照——真坏 5 局全拦(floor 拦 2/测试员拦 3)、无一例旧拦
新放,翻案 2 局均为破案坐实的仪器误杀;n=3 冒烟(mode=v2)全链绿:w2v2-sim
harness verdict.pass=True 而测试员 reject(机械放行测试员逮住,盘上双证)、
w2v2-sim-fix 现象反馈两轮回喂→fix2 收敛(配方显示修成,局内截图亲眼);
rg 零残留(模板残留均为「验收不读它」的降级调试接口合规形态);真跑 ¥8.4。

已知余项:repairFeedback 有产地、Service 续修环消费者未接(测试员 reject 不入
RepairMiddleware,续修判据仍四门)——是否接入随波3 校准数据一并裁;v1 配置级
回退窗口关闭为创始人知悉项,genconfig 默认 mode 仍 shadow、切 v2 归创始人。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:21:04 -07:00

159 lines
9.1 KiB
Python

"""
test_genre_puzzle_assets.py — 解谜品类范例资产的「范例签名 == 现契约」机器校验门(W-GENRE 品类件③④配套)。
防什么(设计档 §3 决策⑥/§6「新漂移面」):per-genre 脚手架与 few-shot 正例是给生成 agent 学的范例,
插件 API 改签名后若范例没同步,它们就成了**下一代漂移源**(教 AI 写已不存在的调用)。本门把
「范例调的每个 API 在现契约(api.d.ts)里真实存在 + 结构契约/红线/形状全过」编译成机器检查:
scaffold 克隆范例 → 跑 tools.mjs check(语法/结构契约/红线/API-存在静态门/形状静态门)→ 必须 PASS。
纯机械确定(shell-out node + 断言退出码),零 LLM、零网络;改坏范例签名 → 本测试红 → CI 拦。
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_genre_puzzle_assets.py
(需 node 在 PATH——与 cheap_run 的 shell-out 前提一致。)
"""
import json
import shutil
import subprocess
from pathlib import Path
_REPO = Path(__file__).resolve().parents[2]
_GAME_RUNTIME = _REPO / "game-runtime"
_TOOLS = _GAME_RUNTIME / "tools" / "amodel-gen" / "tools.mjs"
# 解谜品类的两件范例资产:轻脚手架(件②)+ few-shot 过门正例(件③)。
_PUZZLE_EXEMPLARS = ("_template-puzzle", "_fewshot-puzzle")
# 校验用临时 gameId 前缀(scaffold 落 games/amgen-<id>/,测后清理;与生成产物 p11b-*/cheap-* 不串)。
_CHECK_ID_PREFIX = "gate-puzzle-sig-"
def _scaffold_and_check(template_name: str) -> tuple:
"""scaffold 克隆范例 → tools.mjs check;返回 (scaffold_ok, check_ok, check_output)。测后清理产物目录。"""
gid = _CHECK_ID_PREFIX + template_name.lstrip("_")
game_dir = _GAME_RUNTIME / "games" / f"amgen-{gid}"
try:
r1 = subprocess.run(
["node", str(_TOOLS), "scaffold-saa", gid, template_name],
cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=60, check=False,
)
# 模板缺失时 scaffoldSaa 会回落 _template(带 warn)——那也是失败态:范例目录没了/改名了,必须红。
fell_back = "回落 _template" in (r1.stdout + r1.stderr)
scaffold_ok = r1.returncode == 0 and not fell_back
if not scaffold_ok:
return False, False, (r1.stdout + r1.stderr).strip()
r2 = subprocess.run(
["node", str(_TOOLS), "check", gid],
cwd=str(_GAME_RUNTIME), capture_output=True, text=True, timeout=60, check=False,
)
return True, r2.returncode == 0, (r2.stdout + r2.stderr).strip()
finally:
shutil.rmtree(game_dir, ignore_errors=True) # 清理临时产物,不留脏目录
def test_puzzle_exemplars_pass_contract_check():
"""两件解谜范例(轻脚手架 + few-shot 正例)scaffold 后都必须过 check(范例签名 == 现契约)。"""
for tmpl in _PUZZLE_EXEMPLARS:
scaffold_ok, check_ok, out = _scaffold_and_check(tmpl)
assert scaffold_ok, f"{tmpl}: scaffold 失败/回落(范例目录缺失或克隆坏):{out[:500]}"
assert check_ok, f"{tmpl}: tools.mjs check 未过(范例签名漂移出现契约):{out[:800]}"
def test_puzzle_exemplars_forensics_contract_present():
"""静态兜底:两件范例的 game-logic.js 必须带现行输入契约面(createGame/handleTap/bundle.tick)。
W-AXIS-V2 波2:取证契约(targets/occupied)随 tap-targets 驱动器退役,不再是范例必备面;
_forensicsView 降级为可选调试接口(范例保留实现但不作断言)。
"""
for tmpl in _PUZZLE_EXEMPLARS:
src = (_GAME_RUNTIME / "games" / tmpl / "src" / "game-logic.js").read_text(encoding="utf-8")
for needle in ("export function createGame({ plugins, bundle, viewport })",
"handleTap", "bundle.tick"):
assert needle in src, f"{tmpl}: game-logic.js 缺契约面「{needle}」"
def test_puzzle_fewshot_has_remix_meta():
"""few-shot 正例必须带同款钩子元数据 GENRE_META(品类原型 + 主题标签,L4 传播结构范例)。"""
core = (_GAME_RUNTIME / "games" / "_fewshot-puzzle" / "src" / "core.js").read_text(encoding="utf-8")
assert "GENRE_META" in core and "archetype" in core and "themeTags" in core, \
"few-shot 正例缺 GENRE_META(genre/archetype/themeTags)——同款钩子范例面"
def test_puzzle_genre_rubric_fixture_shape():
"""品类 rubric fixture(件④)形状校验:≥4 条、逐条 0/1 可判、标层 L2/L3/L4、带正反例(质量模型 §3.3 最小形状)。"""
p = Path(__file__).resolve().parents[1] / "fixtures" / "genre-rubrics" / "puzzle.json"
assert p.is_file(), "缺 fixtures/genre-rubrics/puzzle.json(品类 rubric 机器版)"
data = json.loads(p.read_text(encoding="utf-8"))
assert data.get("genre") == "puzzle"
items = data.get("items")
assert isinstance(items, list) and len(items) >= 4, "品类扩展须 ≥4 条(§3.3 规范二)"
for it in items:
assert isinstance(it.get("name"), str) and it["name"], "条目缺 name"
assert it.get("layer") in ("L2", "L3", "L4"), f"条目 {it.get('name')} 层标非法:{it.get('layer')}"
assert isinstance(it.get("meaning"), str) and it["meaning"], f"条目 {it.get('name')} 缺一句可判定判据"
assert isinstance(it.get("positive"), str) and it["positive"], f"条目 {it.get('name')} 缺正例"
assert isinstance(it.get("negative"), str) and it["negative"], f"条目 {it.get('name')} 缺反例"
def _import_cheap_verify():
"""import cheap_verify(tests/ 直跑时补 sys.path;纯函数路不依赖 agentscope)。"""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import cheap_verify
return cheap_verify
def test_genre_loader_and_none_path_identity():
"""④接线不变量:genre=None/缺 fixture → loader 返 [];judge prompt 与无品类版**逐字节一致**(评分尺零漂移)。"""
cv = _import_cheap_verify()
assert cv.load_genre_checklist(None) == []
assert cv.load_genre_checklist("") == []
assert cv.load_genre_checklist("no-such-genre-xyz") == [], "缺 fixture 必须退化为空扩展"
items = cv.load_genre_checklist("puzzle")
assert len(items) >= 4 and all(len(t) == 3 and t[1] in ("L2", "L3", "L4") for t in items)
src = "// demo src"
assert cv._build_judge_user(src, "b") == cv._build_judge_user(src, "b", genre_key="puzzle", genre_checklist=None), \
"无品类条目时 judge prompt 必须与既有版本逐字节一致(锚定纪律)"
# 品类段真的进 prompt(有品类时;主干契约=单 checks 数组、通用底座在前、品类条目紧随)。
p = cv._build_judge_user(src, "b", genre_key="puzzle", genre_checklist=tuple(items))
n_base = len(cv.RICHNESS_CHECKLIST) # 12(2026-07-03 ⑨升第 12 条)
assert "品类扩展清单" in p and items[0][0] in p and f"共 {n_base + len(items)} 条" in p
def test_genre_parse_separate_subtotals():
"""④解析:品类段独立解析出 result["genre"](分母独立小计);无 genre_checklist 时无新增键。
主干契约 = **单 checks 数组**(通用 11 条在前、品类条目紧随其后),输出嵌套 richness.genre
{key,hits,score,max,groups}——与 TRPG 品类件同一机制(merge 收敛:分支原 genreChecks 双数组契约弃用)。
"""
cv = _import_cheap_verify()
items = tuple(cv.load_genre_checklist("puzzle"))
base_checks = [{"name": n, "hit": True, "why": "ok"} for (n, _l, _m) in cv.RICHNESS_CHECKLIST]
genre_checks = [{"name": n, "hit": (i % 2 == 0), "why": "ok"} for i, (n, _l, _m) in enumerate(items)]
text = json.dumps({"checks": base_checks + genre_checks, "notes": "n"}, ensure_ascii=False)
r = cv.parse_judge_output(text, genre_checklist=items, genre_key="puzzle")
assert r["degraded"] is False and r["score"] == len(cv.RICHNESS_CHECKLIST)
g = r["genre"]
assert g["key"] == "puzzle" and g["max"] == len(items) and len(g["hits"]) == len(items)
gg = g["groups"]
layer_max = {ly: sum(1 for (_n, l, _m) in items if l == ly) for ly in ("L2", "L3", "L4")}
assert {ly: gg[ly]["max"] for ly in gg} == layer_max, "品类 groups 分母应来自品类清单(独立小计)"
assert g["score"] == sum(1 for i in range(len(items)) if i % 2 == 0)
# 模型漏答品类段(checks 只有通用底座条目)→ 扩展全 0、底座不受影响。
r2 = cv.parse_judge_output(json.dumps({"checks": base_checks, "notes": "n"}),
genre_checklist=items, genre_key="puzzle")
assert r2["score"] == len(cv.RICHNESS_CHECKLIST) and all(not h["hit"] for h in r2["genre"]["hits"])
# 无品类路:输出键集与既有版本一致(不带 genre 键)。
r3 = cv.parse_judge_output(json.dumps({"checks": base_checks, "notes": "n"}))
assert "genre" not in r3
if __name__ == "__main__":
# 裸跑模式(与本目录其它测试同款:无 pytest 也可直接 python 执行)。
test_puzzle_exemplars_pass_contract_check()
test_puzzle_exemplars_forensics_contract_present()
test_puzzle_fewshot_has_remix_meta()
test_puzzle_genre_rubric_fixture_shape()
test_genre_loader_and_none_path_identity()
test_genre_parse_separate_subtotals()
print("test_genre_puzzle_assets: 全部通过")