固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。 将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。 同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
412 lines
22 KiB
Python
412 lines
22 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 hashlib
|
||
import json
|
||
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
|
||
import pytest # noqa: E402
|
||
|
||
# game_dir 与 dedup 登记表在本模块测试期间重定向到临时目录:materialize、worker 接线、create 路(D9 dedup)
|
||
# 的落盘都进临时目录,不碰真 games/ 与真登记表(cheap_modify、worker_service 与本模块同引用一个 cheap_run
|
||
# 模块对象,改模块属性即对三者同时生效)。重定向收进 autouse fixture:只在本模块测试期间生效、跑完即恢复
|
||
# 原值,不再像早先那样在 import 时无恢复地替换 cheap_run.game_dir——那样会污染同会话后续的 test_run /
|
||
# test_studio_smoke / test_toolkit(它们按真 games/ 根校验 write 边界,game_dir 被换后一写就判越界)。
|
||
def _install_tmp_redirect():
|
||
"""把 cheap_run.game_dir、dedup.DEDUP_REGISTRY 指向新建临时目录,返回二者原值供恢复。"""
|
||
orig = (cheap_run.game_dir, dedup.DEDUP_REGISTRY)
|
||
tmp_games = Path(tempfile.mkdtemp(prefix="a11-m3-games-"))
|
||
cheap_run.game_dir = lambda gid: tmp_games / f"amgen-{gid}" # noqa: E731
|
||
dedup.DEDUP_REGISTRY = Path(tempfile.mkdtemp(prefix="a11m3-dedup-")) / "_dedup_registry.jsonl"
|
||
return orig
|
||
|
||
|
||
@pytest.fixture(autouse=True, scope="module")
|
||
def _tmp_redirect():
|
||
orig_game_dir, orig_registry = _install_tmp_redirect()
|
||
try:
|
||
yield
|
||
finally:
|
||
cheap_run.game_dir = orig_game_dir
|
||
dedup.DEDUP_REGISTRY = orig_registry
|
||
|
||
_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(), "越界路径必须被跳过"
|
||
|
||
|
||
def test_deterministic_v3_reject_uses_one_restricted_writer(monkeypatch):
|
||
"""A11 确定性改首轮 v3 verified reject 后只调用一次受限 writer,并以复验结果收口。"""
|
||
import cheap_verify
|
||
|
||
gid = "det-v3-repair"
|
||
gd = cheap_run.game_dir(gid)
|
||
gd.mkdir(parents=True, exist_ok=True)
|
||
(gd / "entry-bundle.js").write_text("// entry", encoding="utf-8")
|
||
origin_brief = "一个数字华容道解谜"
|
||
source = {"scaffoldTemplate": "_template-puzzle",
|
||
"originBrief": origin_brief,
|
||
"originBriefHash": hashlib.sha256(origin_brief.encode("utf-8")).hexdigest(),
|
||
"files": {"src/core.js": "export const ROUND_MS = 30000;\n"}}
|
||
patch = {"target": {"kind": "config"}, "payload": {"value": {"ROUND_MS": 20000}}}
|
||
monkeypatch.setattr(cheap_run, "build", lambda game_id: {"ok": True, "output": ""})
|
||
monkeypatch.setattr(cheap_run, "stage", lambda game_id: {"ok": True, "output": ""})
|
||
monkeypatch.setattr(cheap_run, "smoke", lambda *a, **k: {"ok": True})
|
||
verdict = {"pass": False, "guards": {name: {"pass": True} for name in cheap_verify._FLOOR_GATES}}
|
||
monkeypatch.setattr(cheap_run, "play", lambda *a, **k: {"ok": True, "verdict": verdict})
|
||
monkeypatch.setattr(cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v3"})
|
||
|
||
async def _reject(req):
|
||
return {"runId": "a11-r1", "floor": {"pass": True},
|
||
"artifactHash": "a" * 64,
|
||
"acceptanceRequestHash": req["acceptanceIdentity"]["acceptanceRequestHash"],
|
||
"decision": {"outcome": "reject", "accepted": False, "publishFrozen": True,
|
||
"repairEligible": True, "repairFeedback": "修闭环", "parentChainCostRmb": 0.2},
|
||
"compatibility": {"accepted": False, "ok": False, "acceptanceVersion": "v3", "trace": {}}}
|
||
|
||
monkeypatch.setattr(cheap_verify, "run_acceptance_v3", _reject)
|
||
monkeypatch.setattr(cheap_verify, "is_v3_repair_authorized",
|
||
lambda payload: (payload.get("decision") or {}).get("repairEligible") is True)
|
||
monkeypatch.setattr(cheap_verify, "is_v3_publishable",
|
||
lambda payload: (payload.get("decision") or {}).get("outcome") == "accept")
|
||
repair_calls = []
|
||
|
||
def _repair(game_id, intent, **kwargs):
|
||
repair_calls.append((game_id, intent, kwargs))
|
||
return {"accepted": True, "acceptanceVersion": "v3", "verdictFull": verdict,
|
||
"acceptanceV3": {"runId": "a11-r2", "decision": {"outcome": "accept", "accepted": True}}}
|
||
|
||
monkeypatch.setattr(M, "_default_rewrite", _repair)
|
||
out = M.execute_deterministic_modify(
|
||
gid, source, patch, base_version_id="base-1", task_trace_id="trace-det-v3-repair")
|
||
assert out["status"] == "succeeded" and len(repair_calls) == 1
|
||
_, intent, kwargs = repair_calls[0]
|
||
assert intent == "修闭环" and kwargs["acceptance_repair_count"] == 1
|
||
assert kwargs["acceptance_parent_run_id"] == "a11-r1"
|
||
assert kwargs["acceptance_brief"] == "一个数字华容道解谜", "复验沿用原游戏 brief,不得换成修复反馈"
|
||
assert kwargs["genre"] == "puzzle"
|
||
acceptance = out["acceptance"]
|
||
assert acceptance["acceptanceV3FirstPass"]["runId"] == "a11-r1"
|
||
assert acceptance["repairAttempted"] is True and acceptance["acceptedAfterRepair"] is True
|
||
|
||
|
||
def test_a11_origin_brief_requires_nonempty_self_consistent_source_lineage():
|
||
"""fresh worker 只能从 SourceProject 恢复原题面;缺失、空值和伪 hash 均 fail-closed。"""
|
||
brief = "原始解谜题面"
|
||
valid = {"originBrief": brief,
|
||
"originBriefHash": hashlib.sha256(brief.encode("utf-8")).hexdigest()}
|
||
assert M._resolve_a11_origin_brief(valid) == (brief, valid["originBriefHash"])
|
||
assert M._resolve_a11_origin_brief({}) == (None, None)
|
||
assert M._resolve_a11_origin_brief({"originBrief": "", "originBriefHash": hashlib.sha256(b"").hexdigest()}) == (None, None)
|
||
assert M._resolve_a11_origin_brief({"originBrief": brief, "originBriefHash": "a" * 64}) == (None, None)
|
||
|
||
|
||
# ───────────────────────── ④ 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, **kw: 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.guards 带完整四门(W-AXIS-V2 预筛权威 = 四门投影;build_result_out 据它算 status)。
|
||
"verdict": {"pass": True, "guards": {"A_boot": {"pass": True}, "B_uncaught": {"pass": True},
|
||
"C_frame": {"pass": True}, "D_render": {"pass": True}}},
|
||
"error": None, "stage": "play"}
|
||
profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"}
|
||
state = W.WorkerState(modify_fn=lambda *a, **kw: 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": '{"scaffoldTemplate":"_template-puzzle"}',
|
||
"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 带新版源(供下一次链式改)"
|
||
assert json.loads(p["sourceProject"])["scaffoldTemplate"] == "_template-puzzle"
|
||
|
||
|
||
def test_worker_modify_summary_preserves_v3_sealed_identity():
|
||
"""deterministic 包装层不得丢 acceptanceV3,否则 result_out 会错误回落 legacy。"""
|
||
sealed = {
|
||
"runId": "run-1", "artifactHash": "a" * 64, "briefHash": "b" * 64,
|
||
"acceptanceRequestHash": "c" * 64, "floor": {"pass": True},
|
||
"decision": {"outcome": "accept", "accepted": True, "publishFrozen": False},
|
||
"compatibility": {"accepted": True, "ok": True, "acceptanceVersion": "v3", "trace": {}},
|
||
}
|
||
result = {"status": "succeeded", "stage": "play", "verdict": None,
|
||
"acceptance": {"acceptanceV3": sealed}}
|
||
summary = W._modify_summary(result, "g1")
|
||
assert summary["acceptanceV3"] is sealed
|
||
assert summary["acceptanceRunId"] == "run-1"
|
||
assert summary["acceptanceArtifactHash"] == "a" * 64
|
||
assert summary["acceptanceBriefHash"] == "b" * 64
|
||
assert summary["acceptanceRequestHash"] == "c" * 64
|
||
|
||
|
||
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, "guards": {
|
||
"A_boot": {"pass": True}, "B_uncaught": {"pass": True},
|
||
"C_frame": {"pass": True}, "D_render": {"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__":
|
||
_install_tmp_redirect() # 独立运行(非 pytest,autouse fixture 不触发):单进程一次性装重定向,退出即释放、无需恢复
|
||
_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)
|