两段式第二段的 regenerate-module 类:改玩法 = 据 intent 有界重写 game-logic.js,不动其余。
复用机制(参数化、不复制 resume/熔断/收口):
- cheap_studio.run_studio 加 4 个默认 None 可选参(system_prompt/initial_kick/write_whitelist/prepare)
切到 modify 态,create 路零行为变化(params=None 等价原逻辑)
- cheap_toolkit.build_toolkit 加 write_whitelist(非白名单 basename 写直接拒)
- cheap_roles.build_modify_system_prompt(复用 create 红线契约块)
cheap_modify.execute_regenerate_modify:
- 取 intent(空→failed)→ materialize base 源 → 重写前后对非目标文件算 hash
- 有界重写(写边界收窄到只 game-logic.js)→ 九门
- status=succeeded ⟺ 九门过 ∧ 非目标稳;manifest{file,kind:behavior,intent,changed,untouchedStable}(断言②地基)
worker_service._process_regenerate_job(regen_fn):把 M3 的 regenerate-module 显式 failed 占位换成真执行;
deterministic/create/生成路一字未动。
测试:test_a11_m4_regenerate 10/10(全注入桩零 LLM)+ 全回归独立复跑 73 测试全绿。
一次真 LLM smoke(单跑):intent=连击递增 → status=succeeded、untouchedStable=True、九门 pass、
attempts=1、¥0.31、137s;独立 diff 佐证 5 非目标文件字节相同、仅 game-logic.js 变。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
301 lines
16 KiB
Python
301 lines
16 KiB
Python
"""test_a11_m3_deterministic.py — A11 切片三 M3「确定性类纠错执行」单测(execution 段零 LLM)。
|
|
|
|
守的不变量:
|
|
· apply_deterministic_modify(纯函数):config 改 core.js 一处常量 / asset 改 assets.js 一处资产键 /
|
|
多常量一次改 / 字符串·数组值 / 多行常量(Object.freeze)/ **no-op 检测**(目标找不到 → found=false、ok=false,
|
|
断言①地基)/ 坏输入容错不抛。
|
|
· materialize_source_project:base 源全 files(含 L1 plumbing)系统级落盘到 game_dir(绕 write_file 的 L1 守卫)。
|
|
· worker 接线:modifyMode=deterministic 走确定性执行(manifest 附进 trace、成功带新版 sourceProject);
|
|
regenerate-module → 显式 failed(不静默吞);create 路(无 modifyMode)零副作用、不调 modify_fn。
|
|
|
|
跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_a11_m3_deterministic.py
|
|
"""
|
|
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
|
|
import cheap_run # noqa: E402
|
|
import cheap_modify as M # noqa: E402
|
|
import worker_service as W # noqa: E402
|
|
import dedup # noqa: E402
|
|
|
|
# 把 game_dir 重定向到临时目录:materialize / worker 接线测试落盘免污染真 games/(与 cheap_modify、worker_service
|
|
# 同引用一个 cheap_run 模块对象 → patch 模块属性即对二者生效)。
|
|
_TMP_GAMES = Path(tempfile.mkdtemp(prefix="a11-m3-games-"))
|
|
cheap_run.game_dir = lambda gid: _TMP_GAMES / f"amgen-{gid}" # noqa: E731
|
|
# create 路会调 D9 dedup.check_similarity(写 FS 登记表)→ 重定向防污染真登记表(与 test_worker_service 同范式)。
|
|
dedup.DEDUP_REGISTRY = Path(tempfile.mkdtemp(prefix="a11m3-dedup-")) / "_dedup_registry.jsonl"
|
|
|
|
_GOOD_BUNDLE = "var __GameBundle=(function(){return{bootGameHost(){}}})();"
|
|
|
|
|
|
# ───────────────────────── ① apply_deterministic_modify ─────────────────────────
|
|
|
|
def test_config_change_round_ms():
|
|
"""config:ROUND_MS 30000→20000,core.js 那一处变、别处不动、manifest 命中、ok=true。"""
|
|
sp = {"files": {"src/core.js": "// core\nexport const ROUND_MS = 30000;\nexport const PAD = 4;\n"}}
|
|
patch = {"target": {"kind": "config", "path": "core.js#ROUND_MS"}, "payload": {"value": {"ROUND_MS": 20000}}}
|
|
out = M.apply_deterministic_modify(sp, patch)
|
|
assert out["ok"] is True, out
|
|
assert "export const ROUND_MS = 20000;" in out["files"]["src/core.js"]
|
|
assert "export const PAD = 4;" in out["files"]["src/core.js"], "别的常量不应被动"
|
|
rec = out["manifest"][0]
|
|
assert rec["found"] is True and rec["key"] == "ROUND_MS"
|
|
assert rec["old"] == "30000" and rec["new"] == "20000"
|
|
assert rec["file"].endswith("core.js") and rec["kind"] == "config"
|
|
|
|
|
|
def test_asset_change_image_file():
|
|
"""asset:assets.js IMAGE_FILES 的 heroSprite 换文件名,manifest 命中、别的资产不动。"""
|
|
sp = {"files": {"src/assets.js": "export const IMAGE_FILES = { heroSprite: 'hero.png', bg: 'bg.jpg' };\n"}}
|
|
patch = {"target": {"kind": "asset", "path": "assets.js#heroSprite"},
|
|
"payload": {"value": {"heroSprite": "hero-green.png"}}}
|
|
out = M.apply_deterministic_modify(sp, patch)
|
|
assert out["ok"] is True, out
|
|
assert "hero-green.png" in out["files"]["src/assets.js"]
|
|
assert "bg.jpg" in out["files"]["src/assets.js"], "别的资产键不应被动"
|
|
rec = out["manifest"][0]
|
|
assert rec["found"] is True and rec["old"] == "hero.png" and rec["new"] == "hero-green.png"
|
|
assert rec["kind"] == "asset"
|
|
|
|
|
|
def test_asset_base_url_change():
|
|
"""asset:baseUrl 特例 → 换 loadAssets 默认资产根。"""
|
|
sp = {"files": {"src/assets.js": "const root = baseUrl == null ? './assets/' : baseUrl;\n"}}
|
|
patch = {"target": {"kind": "asset"}, "payload": {"value": {"baseUrl": "https://cdn.x/g/"}}}
|
|
out = M.apply_deterministic_modify(sp, patch)
|
|
assert out["ok"] is True, out
|
|
assert "https://cdn.x/g/" in out["files"]["src/assets.js"]
|
|
assert out["manifest"][0]["old"] == "./assets/"
|
|
|
|
|
|
def test_noop_const_not_found():
|
|
"""no-op(断言①地基):目标常量不存在 → found=false、整体 ok=false、带 error。"""
|
|
sp = {"files": {"src/core.js": "export const ROUND_MS = 30000;\n"}}
|
|
patch = {"target": {"kind": "config"}, "payload": {"value": {"NOT_THERE": 5}}}
|
|
out = M.apply_deterministic_modify(sp, patch)
|
|
assert out["ok"] is False, "no-op 不应算真改动"
|
|
assert out["manifest"][0]["found"] is False
|
|
assert out["error"]
|
|
# 文本未被改动。
|
|
assert out["files"]["src/core.js"] == "export const ROUND_MS = 30000;\n"
|
|
|
|
|
|
def test_noop_same_value():
|
|
"""no-op:新值与旧值相同(常量命中但无变化)→ ok=false。"""
|
|
sp = {"files": {"src/core.js": "export const SPEED = 5;\n"}}
|
|
patch = {"target": {"kind": "config"}, "payload": {"value": {"SPEED": 5}}}
|
|
out = M.apply_deterministic_modify(sp, patch)
|
|
assert out["manifest"][0]["found"] is True, "常量命中"
|
|
assert out["ok"] is False, "命中但值未变 = no-op"
|
|
|
|
|
|
def test_string_array_and_multi_const():
|
|
"""字符串值 / 数组值 / 多常量一次改 —— 都支持,manifest 三条全命中。"""
|
|
sp = {"files": {"src/core.js": (
|
|
"export const SPEED = 5;\n"
|
|
"export const LABEL = 'old';\n"
|
|
"export const KEYS = ['a','b'];\n"
|
|
)}}
|
|
patch = {"target": {"kind": "config"},
|
|
"payload": {"value": {"SPEED": 8, "LABEL": "new", "KEYS": ["x", "y", "z"]}}}
|
|
out = M.apply_deterministic_modify(sp, patch)
|
|
assert out["ok"] is True, out
|
|
txt = out["files"]["src/core.js"]
|
|
assert "export const SPEED = 8;" in txt
|
|
assert 'export const LABEL = "new";' in txt, "字符串值 → JS 字符串字面量(json 双引号合法)"
|
|
assert 'export const KEYS = ["x", "y", "z"];' in txt, "数组值 → JS 数组字面量"
|
|
assert len(out["manifest"]) == 3 and all(r["found"] for r in out["manifest"])
|
|
|
|
|
|
def test_config_searches_other_l3_when_not_in_core():
|
|
"""config 常量不在 core.js 时,按优先序搜其它 L3(game-logic.js)并命中。"""
|
|
sp = {"files": {
|
|
"src/core.js": "export const A = 1;\n",
|
|
"src/game-logic.js": "export const SPAWN_RATE = 2;\n",
|
|
}}
|
|
patch = {"target": {"kind": "config"}, "payload": {"value": {"SPAWN_RATE": 9}}}
|
|
out = M.apply_deterministic_modify(sp, patch)
|
|
assert out["ok"] is True, out
|
|
assert "export const SPAWN_RATE = 9;" in out["files"]["src/game-logic.js"]
|
|
assert out["manifest"][0]["file"].endswith("game-logic.js")
|
|
|
|
|
|
def test_multiline_const_replace():
|
|
"""多行常量(Object.freeze 跨行)→ 多行兜底正则替换,且不吞掉其后的常量。"""
|
|
sp = {"files": {"src/core.js": (
|
|
"export const PALETTE = Object.freeze([\n"
|
|
" { fill: '#fff', score: 1 },\n"
|
|
" { fill: '#000', score: 2 },\n"
|
|
"]);\n"
|
|
"export const NEXT = 7;\n"
|
|
)}}
|
|
patch = {"target": {"kind": "config"}, "payload": {"value": {"PALETTE": [1, 2, 3]}}}
|
|
out = M.apply_deterministic_modify(sp, patch)
|
|
assert out["ok"] is True, out
|
|
txt = out["files"]["src/core.js"]
|
|
assert "export const PALETTE = [1, 2, 3];" in txt
|
|
assert "export const NEXT = 7;" in txt, "多行替换必须锚到本常量行尾分号,不能吞掉后续常量"
|
|
|
|
|
|
def test_level_complex_layout_unsupported():
|
|
"""level:复杂布局重排(value 非 {常量名:新值})→ unsupported、ok=false。"""
|
|
sp = {"files": {"src/core.js": "export const X = 1;\n"}}
|
|
out = M.apply_deterministic_modify(sp, {"target": {"kind": "level"}, "payload": {"layout": [[1, 0], [0, 1]]}})
|
|
assert out["ok"] is False and "level" in (out["error"] or "")
|
|
|
|
|
|
def test_level_const_text_replace_supported():
|
|
"""level:关卡常量文本替换(payload.value={常量名:新值})与 config 同路,支持。"""
|
|
sp = {"files": {"src/core.js": "export const LEVEL_ROWS = 8;\n"}}
|
|
out = M.apply_deterministic_modify(sp, {"target": {"kind": "level"}, "payload": {"value": {"LEVEL_ROWS": 12}}})
|
|
assert out["ok"] is True
|
|
assert "export const LEVEL_ROWS = 12;" in out["files"]["src/core.js"]
|
|
assert out["manifest"][0]["kind"] == "level"
|
|
|
|
|
|
def test_tolerant_bad_inputs():
|
|
"""坏 source_project / 坏 patch / 非确定性 kind —— 容错返 ok=false,绝不抛。"""
|
|
assert M.apply_deterministic_modify("{not json", {"target": {"kind": "config"},
|
|
"payload": {"value": {"X": 1}}})["ok"] is False
|
|
assert M.apply_deterministic_modify(None, {})["ok"] is False
|
|
assert M.apply_deterministic_modify({"files": {"src/core.js": "export const X = 1;\n"}}, "{bad")["ok"] is False
|
|
assert M.apply_deterministic_modify({"files": {"src/core.js": "x"}}, None)["ok"] is False
|
|
out = M.apply_deterministic_modify({"files": {"src/core.js": "x"}},
|
|
{"target": {"kind": "behavior"}, "payload": {"value": {"X": 1}}})
|
|
assert out["ok"] is False and "behavior" in (out["error"] or "")
|
|
|
|
|
|
def test_apply_does_not_mutate_input():
|
|
"""apply 不原地改入参 files(返回的是改后拷贝)。"""
|
|
sp = {"files": {"src/core.js": "export const A = 1;\n"}}
|
|
M.apply_deterministic_modify(sp, {"target": {"kind": "config"}, "payload": {"value": {"A": 9}}})
|
|
assert sp["files"]["src/core.js"] == "export const A = 1;\n", "入参 files 不应被原地改动"
|
|
|
|
|
|
# ───────────────────────── ② materialize_source_project ─────────────────────────
|
|
|
|
def test_materialize_writes_all_files_incl_l1():
|
|
"""全 files(含 L1 plumbing host-config/game/index/entry)落盘到 game_dir(系统落盘绕 L1 守卫)。"""
|
|
files = {
|
|
"src/core.js": "export const A = 1;\n",
|
|
"src/host-config.js": "// L1 plumbing host-config\n",
|
|
"src/game.js": "// L1 wrapper game\n",
|
|
"index.html": "<html><body>g</body></html>",
|
|
"entry-bundle.js": "// L1 entry\n",
|
|
}
|
|
gd = M.materialize_source_project("mat1", files)
|
|
assert (gd / "src" / "core.js").read_text(encoding="utf-8") == "export const A = 1;\n"
|
|
assert (gd / "src" / "host-config.js").exists(), "L1 host-config 也应落盘(系统落盘不拦 L1)"
|
|
assert (gd / "src" / "game.js").exists(), "L1 game.js 也应落盘"
|
|
assert (gd / "index.html").read_text(encoding="utf-8") == "<html><body>g</body></html>"
|
|
assert (gd / "entry-bundle.js").exists(), "L1 entry-bundle 也应落盘"
|
|
|
|
|
|
def test_materialize_skips_escape_path():
|
|
"""越界路径(../ 逃逸)被跳过、不写到 game_dir 外,且不抛。"""
|
|
gd = M.materialize_source_project("mat2", {"../escape.js": "x", "src/ok.js": "y"})
|
|
assert (gd / "src" / "ok.js").exists()
|
|
assert not (gd.parent / "escape.js").exists(), "越界路径必须被跳过"
|
|
|
|
|
|
# ───────────────────────── ④ worker 接线 ─────────────────────────
|
|
|
|
def test_worker_deterministic_route_attaches_manifest_on_failed():
|
|
"""modifyMode=deterministic → 走确定性执行;失败(no-op)路也把 manifest/error 附进 trace 回调。"""
|
|
captured = {}
|
|
fake = {"status": "failed",
|
|
"manifest": [{"file": "src/core.js", "kind": "config", "key": "X", "old": None, "new": "5", "found": False}],
|
|
"verdict": None, "error": "no-op:常量未找到", "stage": "apply"}
|
|
state = W.WorkerState(modify_fn=lambda gid, sp, patch: fake,
|
|
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
|
|
profile_fn=lambda j, g: None)
|
|
job = {"job_id": "m1", "traceId": "m1", "modifyMode": "deterministic", "gameId": "nope-x",
|
|
"sourceProject": "{}", "modifyPatch": "{}", "brief": "x", "callback": {"target": "http://cb"}}
|
|
W.process_job(state, job)
|
|
p = captured["payload"]
|
|
assert p["status"] == "failed", "无 bundle + verdict 失败 → failed"
|
|
assert p["trace"]["modifyManifest"][0]["key"] == "X"
|
|
assert p["trace"]["modifyError"] == "no-op:常量未找到"
|
|
|
|
|
|
def test_worker_deterministic_route_success_carries_bundle_and_source():
|
|
"""deterministic 成功 → result-out 带 engineBundle(重建产物)+ manifest + 新版 sourceProject(链式改)。"""
|
|
gid = "m3ok"
|
|
gd = cheap_run.game_dir(gid)
|
|
(gd / "src").mkdir(parents=True, exist_ok=True)
|
|
(gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8")
|
|
(gd / "src" / "core.js").write_text("export const A = 1;\n", encoding="utf-8")
|
|
captured = {}
|
|
fake = {"status": "succeeded",
|
|
"manifest": [{"file": "src/core.js", "kind": "config", "key": "ROUND_MS",
|
|
"old": "30000", "new": "20000", "found": True}],
|
|
"verdict": {"pass": True, "guards": {}}, "error": None, "stage": "play"}
|
|
profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"}
|
|
state = W.WorkerState(modify_fn=lambda *a: fake,
|
|
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
|
|
profile_fn=lambda j, g: profile)
|
|
job = {"job_id": "m2", "traceId": "m2", "modifyMode": "deterministic", "gameId": gid,
|
|
"sourceProject": "{}", "modifyPatch": "{}", "brief": "点点乐", "callback": {"target": "http://cb"}}
|
|
W.process_job(state, job)
|
|
p = captured["payload"]
|
|
assert p["status"] == "succeeded"
|
|
assert "__GameBundle" in p["engineBundle"], "成功路携重建后的 bundle"
|
|
assert p["trace"]["modifyManifest"][0]["key"] == "ROUND_MS"
|
|
assert "sourceProject" in p, "成功路 best-effort 带新版源(供下一次链式改)"
|
|
|
|
|
|
def test_worker_regenerate_module_routes_to_executor_no_llm():
|
|
"""modifyMode=regenerate-module(M4 已实现)→ 走 execute_regenerate_modify、不再占位 failed。
|
|
|
|
无 sourceProject/modifyPatch → intent 空,executor 早失败(零 LLM、不 scaffold/不重写)→ 仍发 failed 回调
|
|
(任务不挂 RUNNING)+ 失败原因进 trace(不静默吞)。验 regen 分流已接真执行器。完整 regen 单测见 test_a11_m4。
|
|
"""
|
|
captured = {}
|
|
state = W.WorkerState(send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
|
|
profile_fn=lambda j, g: None) # 默认 regen_fn → 真 execute_regenerate_modify(intent 空零 LLM)
|
|
job = {"job_id": "rm1", "traceId": "rm1", "modifyMode": "regenerate-module", "gameId": "g",
|
|
"brief": "x", "callback": {"target": "http://cb"}} # 无 sourceProject/modifyPatch
|
|
W.process_job(state, job)
|
|
assert captured["payload"]["status"] == "failed", "intent 空 → failed 回调(任务不挂 RUNNING)"
|
|
assert captured["payload"]["trace"]["modifyError"], "失败原因附进 trace(不静默吞)"
|
|
|
|
|
|
def _boom_modify(*a):
|
|
raise AssertionError("create 路不应调用 modify_fn")
|
|
|
|
|
|
def test_create_path_unaffected_by_modify_wiring():
|
|
"""无 modifyMode 的 create 路:走 run_fn、不触 modify_fn(modify 接线对 create 零副作用)。"""
|
|
gid = "create1"
|
|
gd = cheap_run.game_dir(gid)
|
|
(gd / "src").mkdir(parents=True, exist_ok=True)
|
|
(gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8")
|
|
called = {}
|
|
state = W.WorkerState(
|
|
run_fn=lambda job: (called.update(ran=True) or {"verdict": {"pass": True}}, gd),
|
|
send_fn=lambda u, p, s: called.update(payload=p) or (200, ""),
|
|
profile_fn=lambda j, g: None,
|
|
modify_fn=_boom_modify,
|
|
)
|
|
job = {"job_id": "c1", "traceId": "c1", "brief": "x", "callback": {"target": "http://cb"}} # 无 modifyMode
|
|
W.process_job(state, job)
|
|
assert called.get("ran") is True, "create 路必须走 run_fn"
|
|
assert called["payload"]["status"] == "succeeded"
|
|
|
|
|
|
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[test_a11_m3_deterministic] {len(_fns) - _failed}/{len(_fns)} passed")
|
|
sys.exit(1 if _failed else 0)
|