155 lines
8.9 KiB
Python
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: 全部通过")
|