games-development-ai/cheap-worker/tests/test_genre_puzzle_assets.py

155 lines
8.9 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/_forensicsView/targets/occupied)。"""
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 })",
"_forensicsView", "targets", "occupied", "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: 全部通过")