games-development-ai/cheap-worker/tests/test_edit_file_toolkit.py
lili 882ca3f357 feat(cheap-gen): W-AXIS 波2 判定语义重置——独立模型玩法判定阻断+拆残笼+判定档切 M3
裁定三落地:accepted = 机械预筛 ∧ 独立模型玩法判定,九门 pass 降为「未见明显死」预筛。

- 波2a 玩法判定器(cheap_verify.py):读 brief+run-summary+全程截图(含新增局中连拍
  midplay-1..N,play.cdp.cjs 只读并发拍、不进任何门判据),布尔裁 broken/hollow/off_brief,
  fail-closed;判定真相层落 evidence/judge.json + verdict.json 写回 accepted/judge 段;
  金标夹具 fixtures/judge-golden/ + judge_golden.py 校准跑批
- 波2b 拆残笼:check 词法 tokenizer 替换 stripCode 正则(字符串内 // 假阴根修,292 在册
  源零差异);prompt v1.6.3 外科清洗(两层奖励→纯设计语/删盲驱动器围设计/删熔断恐吓),
  registry 热取==内置逐字节一致(cheap_roles 内置回退同步);edit_file 空白归一+近似片段提示
- 判定器补修:空输出有界重试一次(glm 思考吃光 max_tokens 时 content 空)+重试放大公式
  反缩 bug 修(旧 min(×2,6000) 大 base 反缩)+finishReason 落盘;imagesSeen 模型自报+
  盲检微扰重掷、两掷均盲=仪器故障 degraded(闲鱼中转静默丢图实锤,不再错杀 hollow)
- 判定档切 MiniMax-M3(创始人 2026-07-10 拍板,裁定三①边界随 v2 plan 修订):
  generation.yaml judge.model+max_tokens=202752(512K 网关实探 400 拒,取实探上限);
  3 例盲案 M3 重判全 accept、¥0.026/局
- 三路消费对齐:CLI 预筛语义修(cheap_studio 喂 verdict.pass 而非 finished,违裁定三的
  放行已纠)/Service 路 apply_gameplay_judge 显式 prefilter 参数/result_out 权威改读
  accepted(无键回落预筛,旧产物兼容)+trace.gameplayJudge additive 透传;
  tier2 gate_judge/genconfig 同步判定配置与消费

测试:test_gameplay_judge 33 项新增,全仓 pytest 480 绿;真判定闭环 accept ¥0.061。

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

171 lines
7.0 KiB
Python

"""test_edit_file_toolkit.py — fix①-A 增量 edit 工具单测(死圈修复 2026-07-08 双评审)。
守的不变量:
· edit_file 命中唯一 old → 替换 1 处、其余不动;
· old 未找到(0 次)/ 不唯一(>1 次)→ 明确报错(edit 自身失败面,给结构化提示、纳入纠偏覆盖);
· 回写继承 write_file 的 L1_FIXED 拒改(改 host-config.js 类 plumbing 被拒)与 game_dir 越界拒改;
· 文件不存在时 edit 拒改并提示先 write_file 建。
用真 games/ 下临时 game_dir(amgen-editfixtest*)测真实 _resolve_in_repo + L1 路径,teardown 清理,不碰真产物。
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_edit_file_toolkit.py -q
"""
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_run # noqa: E402
import pytest # noqa: E402
_GID = "editfixtest001"
def _rel(name: str) -> str:
return f"game-runtime/games/amgen-{_GID}/src/{name}"
@pytest.fixture(autouse=True)
def _fixture_game_dir():
gd = cheap_run.game_dir(_GID)
(gd / "src").mkdir(parents=True, exist_ok=True)
# "AAA" 出现两次(不唯一测)、"Math.random()" 一次(命中测)。
(gd / "src" / "game-logic.js").write_text(
"AAA\nfunction rng(){ return ctx ? ctx.random.next() : Math.random(); }\nAAA\n",
encoding="utf-8",
)
try:
yield gd
finally:
shutil.rmtree(gd, ignore_errors=True)
def test_edit_happy_replaces_once():
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), ": Math.random()", ": 0")
assert r["ok"], r
txt = (cheap_run.game_dir(_GID) / "src" / "game-logic.js").read_text(encoding="utf-8")
assert "Math.random()" not in txt
assert "ctx.random.next() : 0" in txt
assert txt.count("AAA") == 2 # 其余不动
def test_edit_old_not_found():
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), "ZZZ_不存在_ZZZ", "x")
assert not r["ok"]
assert "未找到" in r["error"]
def test_edit_old_not_unique():
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), "AAA", "BBB") # 出现两次
assert not r["ok"]
assert "不唯一" in r["error"]
# 拒改后文件未变
assert (cheap_run.game_dir(_GID) / "src" / "game-logic.js").read_text(encoding="utf-8").count("AAA") == 2
def test_edit_l1_rejected():
# 建一个 L1 plumbing 文件再 edit → 回写经 write_file 被 L1_FIXED 拒。
gd = cheap_run.game_dir(_GID)
(gd / "src" / "host-config.js").write_text("const HOST = 1;\n", encoding="utf-8")
r = cheap_run.edit_file(_GID, _rel("host-config.js"), "const HOST = 1;", "const HOST = 2;")
assert not r["ok"]
assert "L1" in r["error"] or "plumbing" in r["error"]
def test_edit_out_of_boundary_rejected():
r = cheap_run.edit_file(_GID, "AGENTS.md", "x", "y") # 仓根文件,越界
assert not r["ok"]
assert "越界" in r["error"]
def test_edit_missing_file_rejected():
r = cheap_run.edit_file(_GID, _rel("nonexist.js"), "x", "y")
assert not r["ok"]
assert "不存在" in r["error"]
# ── W-AXIS 波2:锚点容错(空白归一回退 + 最近似片段提示)──────────────────────────────
def test_edit_ws_normalized_indentation_match():
"""空白归一回退:old 与文件只差行内空白/缩进(审计实证 trpg/story 5 连拒的『一字之差』)→ 归一后一次命中并落 new。"""
gd = cheap_run.game_dir(_GID)
# 文件真实缩进(handleTap 块,贴近生成产物)。
real = (
"export function createGame(){\n"
" function handleTap(x, y) {\n"
" measures.taps += 1;\n"
" const cur = sceneFsm.current();\n"
" if (cur === SCENE_MENU) {\n"
" if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay();\n"
" return;\n"
" }\n"
" }\n"
" return { _forensicsView(){}, handleTap };\n"
"}\n"
)
(gd / "src" / "game-logic.js").write_text(real, encoding="utf-8")
# 模型手抄的 old:缩进全歪(顶格/2 空格)+ 某行尾随空白 —— 逐字节精确必 0 命中。
old = (
"function handleTap(x, y) {\n"
" measures.taps += 1;\n"
" const cur = sceneFsm.current(); \n"
" if (cur === SCENE_MENU) {\n"
" if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) enterPlay();\n"
" return;\n"
" }"
)
new = (
"function handleTap(x, y) {\n"
" measures.taps += 1;\n"
" const cur = sceneFsm.current();\n"
" if (cur === SCENE_MENU) {\n"
" if (menuStartBtn && hudUi.pointInRect(x, y, menuStartBtn)) startGame();\n"
" return;\n"
" }"
)
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), old, new)
assert r["ok"], r
txt = (gd / "src" / "game-logic.js").read_text(encoding="utf-8")
assert "startGame()" in txt and "enterPlay()" not in txt, txt
assert "归一" in r.get("message", ""), r # 明示走了归一路(不静默改语义)
def test_edit_nearest_fragment_hint_on_content_mismatch():
"""内容真不一致(非空白差异,w2a-verify 幻觉式:文件是单行 color:popColor、old 幻觉出多行 tier.mult)→ 精确+归一都不命中 → 报错带最近似片段 + 行号窗口(不做危险模糊匹配)。"""
gd = cheap_run.game_dir(_GID)
real = (
"export function createGame(){\n"
" function spawnPop(star, gain){\n"
" pops.push({ x: star.x, y: star.y, text: popText, color: popColor, age: 0, life: 0.8 });\n"
" }\n"
"}\n"
)
(gd / "src" / "game-logic.js").write_text(real, encoding="utf-8")
old = (
" text: popText,\n"
" color: tier.mult >= 4 ? '#ff5a5a' : (tier.mult >= 3 ? '#ffa500' : COLOR.pop),\n"
" age: 0, life: 0.8,\n"
" });"
)
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), old, "x")
assert not r["ok"]
assert "未找到" in r["error"], r
assert "最接近" in r["error"] and "" in r["error"], r # 最近似片段 + 行号窗口
# 拒改后文件未变(绝不模糊匹配改错地方)。
assert "color: popColor" in (gd / "src" / "game-logic.js").read_text(encoding="utf-8")
def test_edit_ws_normalized_ambiguous_lists_lines():
"""归一后命中多处(内部多空格致精确 0 命中、归一后与两处同)→ 拒改并列出各命中起始行,提示加长 old。"""
gd = cheap_run.game_dir(_GID)
real = "export function createGame(){\n const a = 1;\n const a = 1;\n}\n"
(gd / "src" / "game-logic.js").write_text(real, encoding="utf-8")
r = cheap_run.edit_file(_GID, _rel("game-logic.js"), "const a = 1;", "const a = 2;") # 内部多空格→精确 0 命中
assert not r["ok"]
assert "无法唯一定位" in r["error"], r
assert "第 2/3 行" in r["error"], r # 列出两处命中起始行
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))