games-development-ai/cheap-worker/tests/test_cheap_service_driver.py
lili cbfd4d871b
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(acceptance): 闭合 playtest v3 与 A+ 可信消费链
固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。

将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。

同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
2026-07-28 20:16:13 -07:00

549 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""cheap_service_driver 单测:OpenAI 兼容凭据体(base+/v1) + _build_summary 组 result_out 可吃形 + sidecar 往返 +
worker_service 默认 run_fn 已切到驱动 Service(不再进程内 run_studio)。真起 Service/真门排 mini-desktop 窗口。
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_service_driver.py -v
"""
import sys
import json
import subprocess
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import _bootstrap # noqa: E402,F401
import cheap_run # noqa: E402
import cheap_service_driver as D # noqa: E402
import result_out # noqa: E402
import worker_service as W # noqa: E402
MATCH3_PROFILE_ID = "match3.orthogonal-swap-v1"
def test_credential_payload_openai_compatible_with_v1(monkeypatch):
# 便宜档 M3 走 OpenAI 兼容路:type=openai_credential、base_url 带 /v1、api_key 非空。
monkeypatch.setattr(D, "_resolve_base", lambda: "http://100.64.0.8:3000")
# WU2 §3.7:_resolve_key 现签名 (user_token=None);缺 token 走 env 回落(此桩恒回同一 key)。
monkeypatch.setattr(D, "_resolve_key", lambda user_token=None: "sk-test")
payload = D._cheap_credential_payload()
assert payload["data"]["type"] == "openai_credential"
assert payload["data"]["base_url"].endswith("/v1")
assert payload["data"]["api_key"] == "sk-test"
def test_resolve_key_prefers_user_token_else_env_fallback(monkeypatch):
# WU2 §3.7 F:job 带 userToken 则用它(不碰 env);缺失回落全局 env key(client.get_api_key)。
import worker.client as _wc
monkeypatch.setattr(_wc, "get_api_key", lambda: "sk-env-fallback")
assert D._resolve_key("sk-user-per") == "sk-user-per" # per-user token 优先
assert D._resolve_key(None) == "sk-env-fallback" # 缺失回落 env
assert D._resolve_key("") == "sk-env-fallback" # 空串视同缺失
# 凭据体据 user_token 选 key(有则 per-user、无则回落 env)。
monkeypatch.setattr(D, "_resolve_base", lambda: "http://100.64.0.8:3000")
assert D._cheap_credential_payload("sk-user-per")["data"]["api_key"] == "sk-user-per"
assert D._cheap_credential_payload()["data"]["api_key"] == "sk-env-fallback"
def test_build_summary_shape_feeds_result_out(tmp_path, monkeypatch):
# _build_summary 组的 summary 能被 result_out.build_result_out 吃出 succeeded + trace(costRmb/repairs)。
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
gd = tmp_path / "amgen-g9"
gd.mkdir(parents=True)
(gd / "bundle.iife.js").write_text("var x=1; window.__GameBundle={};", encoding="utf-8")
monkeypatch.setattr(D, "_read_driver_type", lambda gid: "tap-targets")
# 真实 verdict.guards 带完整四门(A/B/C/D)——W-AXIS-V2 预筛权威 = 四门投影;H_progress 保留(降观测门,trace 仍带)。
verdict = {"pass": True, "guards": {"A_boot": {"pass": True}, "B_uncaught": {"pass": True},
"C_frame": {"pass": True}, "D_render": {"pass": True},
"H_progress": {"pass": True}}}
svc = {"costRmb": 0.42, "rmbGate": "active", "repairs": 2, "budgetSoftTripped": False}
turn = {"ended": True, "reason": "REPLY_END"}
summary = D._build_summary("g9", "点击得分小游戏", verdict, svc, turn, t0=0.0)
assert summary["ok"] is True and summary["finished"] is True
assert summary["verdict"]["pass"] is True
assert summary["attempts"] == 3 # repairs=2 → attempts=repairs+1(result_out repairs=attempts-1 反推回 2)
assert summary["costRmb"] == 0.42
assert summary["verdictFull"] == verdict and summary["driverType"] == "tap-targets"
payload = result_out.build_result_out({"traceId": "t", "brief": "点击"}, summary, cheap_run.game_dir("g9"))
assert payload["status"] == "succeeded"
assert payload["trace"]["repairs"] == 2 and payload["trace"]["cost"]["totalRmb"] == 0.42
assert payload["trace"]["gatespec"]["driver"] == "tap-targets"
def test_build_summary_no_verdict_is_failed(tmp_path, monkeypatch):
# 回合没跑出 verdict(续修耗尽/软停无绿)→ ok=False,result_out 走 failed(七值枚举)。
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
(tmp_path / "amgen-g8").mkdir(parents=True) # 无 bundle
monkeypatch.setattr(D, "_read_driver_type", lambda gid: None)
summary = D._build_summary("g8", "brief", None, {"costRmb": 0.1, "rmbGate": "active", "repairs": 6},
{"ended": True, "reason": "REPLY_END"}, t0=0.0)
assert summary["ok"] is False
payload = result_out.build_result_out({"traceId": "t"}, summary, cheap_run.game_dir("g8"))
assert payload["status"] == "failed"
assert payload["failureReason"] in result_out._FAILURE_REASONS
def test_session_cfg_roundtrip_maps_session_to_backend_gameid(tmp_path, monkeypatch):
# C2:driver 按 session_id 写会话注册表(含 external_game_id=后端 gameId),Service 工厂读 session_id → 解析回后端 gameId。
import cheap_service_app as A
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
# create 路:driver 写映射、白名单 None → restricted False。
D._write_session_cfg("sess-abc", external_game_id="70012", write_whitelist=None, scaffold_template=None)
cfg = A._read_session_cfg("sess-abc")
assert A._resolve_external_game_id("sess-abc", cfg) == "70012", "session_id → 后端 gameId"
assert A._resolve_write_whitelist(cfg) is None, "create 路不收窄"
# reskin 路:driver 传白名单 → restricted True + 工厂收窄成 set。
D._write_session_cfg("sess-def", external_game_id="70013", write_whitelist={"game-logic.js"})
cfg2 = A._read_session_cfg("sess-def")
assert cfg2["restricted"] is True
assert A._resolve_write_whitelist(cfg2) == {"game-logic.js"}
# 空集白名单(Minor#4 fail-closed):restricted 必须由「白名单是否给定(is not None)」决定、非 bool(空集会误成 False)。
# 空集 → restricted True → T2 读侧 I1 收窄成空集禁写,不再退化成 restricted False 放开全写。
D._write_session_cfg("sess-empty", external_game_id="70014", write_whitelist=set())
cfg3 = A._read_session_cfg("sess-empty")
assert cfg3["restricted"] is True, "空集白名单 → restricted True(fail-closed,不因 bool(空集)退成 False)"
assert A._resolve_write_whitelist(cfg3) == set(), "T2 读侧空集禁写(不放开全写)"
def test_build_summary_trace_contract_end_to_end(tmp_path, monkeypatch):
# I4:_build_summary 组的 summary 喂 build_result_out 后,trace 七项 + sevenGateVerdict + gatespec.driver +
# cost.totalRmb 齐备,engineBundle 承重;不空口说「result_out 零改动」,而是端到端断言其当前消费字段。
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
gd = tmp_path / "amgen-70012"
gd.mkdir(parents=True)
(gd / "bundle.iife.js").write_text("var x=1; window.__GameBundle={};", encoding="utf-8")
monkeypatch.setattr(D, "_read_driver_type", lambda gid: "tap-targets")
# 真实 verdict.guards 带完整四门(A/B/C/D)——W-AXIS-V2 预筛权威 = 四门投影;H_progress 保留(降观测门,trace 仍带)。
verdict = {"pass": True, "guards": {"A_boot": {"pass": True}, "B_uncaught": {"pass": True},
"C_frame": {"pass": True}, "D_render": {"pass": True},
"H_progress": {"pass": True}}}
svc = {"costRmb": 0.42, "rmbGate": "active", "repairs": 2, "budgetSoftTripped": False}
summary = D._build_summary("70012", "点击得分小游戏", verdict, svc,
{"ended": True, "reason": "REPLY_END"}, t0=0.0)
payload = result_out.build_result_out({"traceId": "t-1", "brief": "点击"}, summary, cheap_run.game_dir("70012"))
assert payload["status"] == "succeeded"
assert payload["traceId"] == "t-1" # 回调关联键 = job.traceId(非 gameId)
assert "__GameBundle" in payload["engineBundle"] # engineBundle 承重入 feed
tr = payload["trace"]
for k in ("pass", "repairs", "wallS", "models", "attempts", "gameId", "stage"):
assert k in tr, f"trace 必填七项缺 {k}"
assert tr["gameId"] == "70012" # C2:trace.gameId = 后端 gameId(诊断可关联)
assert tr["repairs"] == 2 and tr["cost"]["totalRmb"] == 0.42
assert tr["sevenGateVerdict"]["pass"] is True and "H_progress" in tr["sevenGateVerdict"]["guards"]
assert tr["gatespec"]["driver"] == "tap-targets" # 键名 driver(非 driverType,后端 hasDriver 据此)
def test_read_service_run_summary_bounded_poll(tmp_path, monkeypatch):
# C1:sidecar 在 wait 窗口内出现 → 读到;窗口内始终缺失 → 诚实降级 {}(不阻塞、不伪造)。
import json
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
assert D._read_service_run_summary("miss", wait_s=0.05) == {}, "缺失且过期 → 降级 {}"
ev = tmp_path / "amgen-hit" / "evidence"
ev.mkdir(parents=True)
(ev / "service-run-summary.json").write_text(json.dumps({"costRmb": 0.3, "repairs": 1}), encoding="utf-8")
got = D._read_service_run_summary("hit", wait_s=0.05)
assert got["costRmb"] == 0.3 and got["repairs"] == 1
def test_drive_cheap_generation_fake_sse(tmp_path, monkeypatch):
# 契约(fake-SSE):mock httpx + _wait_for_turn_end,驱动真 drive_cheap_generation —— 断言 C2(scaffold 用后端
# gameId、注册表写 external_game_id=后端 gameId)+ 回合真结束读 verdict/sidecar 组 succeeded + 回合未结束诚实降级。
import asyncio
import json
import service.control_plane as CP
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
monkeypatch.setattr(cheap_run, "wg1_game_dir", lambda gid: tmp_path / "_wg1-gen" / gid)
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
# W-AXIS 波1:driver 现在 scaffold 前会 per-run 归档(archive_prior_run 用 _ARCHIVE_DIR 全局)——沙箱到 tmp,
# 否则真驱动会把预铺的 amgen-70012 移进真实仓 game-runtime/games/_amgen-archive(测试隔离泄漏)。
monkeypatch.setattr(cheap_run, "_ARCHIVE_DIR", tmp_path / "_amgen-archive")
scaffolded = {}
monkeypatch.setattr(cheap_run, "scaffold", lambda gid, tpl=None: scaffolded.update(gid=gid) or {"ok": True, "output": ""})
# richness 非阻塞:stub 成同步返回(避免真 LLM)。
async def _fake_richness(gid, *, brief=""): return {"score": 5, "max": 8, "degraded": False}
monkeypatch.setattr("cheap_verify.verify_richness", _fake_richness)
# 无网络/无 key 也能跑:stub 凭据体 + key env(真凭据/旁路真跑在 mini-desktop 窗口验)。
import _bootstrap
monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None)
monkeypatch.setattr(D, "_cheap_credential_payload",
lambda user_token=None: {"data": {"type": "openai_credential", "api_key": "sk", "base_url": "http://x/v1"}})
class _Resp:
def __init__(self, d): self._d = d
def json(self): return self._d
posted = {}
class _FakeHttp:
def __init__(self, *a, **k): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, **k):
if url.endswith("/sessions/"):
posted["session_body"] = k.get("json") # 捕获 session 体(fix400 主防线断言用)
return _Resp({"credential_id": "c1"} if url.endswith("/credential/") else
{"agent_id": "a1"} if url.endswith("/agent/") else
{"session_id": "sess-9"} if url.endswith("/sessions/") else {})
async def patch(self, url, **k): return _Resp({})
import httpx
monkeypatch.setattr(httpx, "AsyncClient", _FakeHttp)
# 造后端 gameId=70012 的 on-disk 产物:bundle 由 scaffold 阶段就位(engineBundle 承重);verdict + 收口 sidecar
# 改为在 fake turn 内写(模拟 Service 回合中跑门写盘)——driver 现在回合前会清残留(红线③修 b),预铺在 drive
# 之前会被清掉,故必须让「本次回合」写入,才对齐真实时序(先清陈旧、回合中写本次、回合后读本次)。
(tmp_path / "amgen-70012").mkdir(parents=True)
(tmp_path / "amgen-70012" / "bundle.iife.js").write_text("window.__GameBundle={};", encoding="utf-8")
# 有界轮询提速:sidecar 缺失时默认等 5s,测试里压到 50ms(保留真实 bounded-poll 逻辑,只缩等待窗口)。
_orig_rss = D._read_service_run_summary
monkeypatch.setattr(D, "_read_service_run_summary", lambda gid, wait_s=0.05: _orig_rss(gid, wait_s=wait_s))
# ① 回合真结束(REPLY_END)→ fake turn 写本次 verdict/sidecar → driver 回合后读它组 succeeded 形。
async def _ended(*a, **k):
vev = tmp_path / "_wg1-gen" / "70012" / "evidence"; vev.mkdir(parents=True, exist_ok=True)
(vev / "verdict.json").write_text(json.dumps({"pass": True, "guards": {
"A_boot": {"pass": True}, "B_uncaught": {"pass": True},
"C_frame": {"pass": True}, "D_render": {"pass": True}}}), encoding="utf-8")
sev = tmp_path / "amgen-70012" / "evidence"; sev.mkdir(parents=True, exist_ok=True)
(sev / "service-run-summary.json").write_text(
json.dumps({"costRmb": 0.5, "rmbGate": "active", "repairs": 1}), encoding="utf-8")
return {"ended": True, "reason": "REPLY_END", "endEvent": {}}
monkeypatch.setattr(CP, "_wait_for_turn_end", _ended)
# W-AXIS-V2 波1:本用例验的是单 POST 驱动器管路(scaffold/session/verdict/sidecar/costRmb → ok 映射)=四门预筛
# 这一半;测试 agent 真玩是独立阻断层、桩环境(无 Chrome/无判定模型)起不了服,故设 acceptance.mode=v1 隔离出
# 管路本身(v1 走 apply_gameplay_judge 不起服测试员)+ 关 blocking 让 ok=预筛(四门投影)。测试员阻断/mode 三态
# 由 test_acceptance_v2.py 隔离覆盖、真玩由考卷真跑覆盖。判定仍 observe-only 跑一遍(无截图 → fail-closed degraded)。
monkeypatch.setenv("TIER2_GEN__ACCEPTANCE__MODE", "v1")
monkeypatch.setenv("TIER2_GEN__JUDGE__BLOCKING", "false")
summary, gdir = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "t9", "brief": "点球"}))
assert scaffolded["gid"] == "70012", "C2:scaffold 用后端 gameId(str),非 session_id"
cfg = json.loads((tmp_path / "_cheap-sessions" / "sess-9.json").read_text(encoding="utf-8"))
assert cfg["external_game_id"] == "70012", "C2:注册表写 session→后端 gameId 映射"
assert summary["ok"] is True and summary["gameId"] == "70012" and summary["costRmb"] == 0.5
# 判定段 additive 落盘(observe-only:blocking=false 不阻断,但如实记裁决;无截图证据 → fail-closed degraded)。
assert summary["judge"]["blocking"] is False and summary["accepted"] is False
assert summary["judge"]["degraded"] is True
assert gdir == cheap_run.game_dir("70012")
# fix400 主防线锚:session parameters 必带 parallel_tool_calls=False(源头禁并行 call,防 M3 经 new-api
# 并行 call 同 index 拼桶 → 非法 JSON → 孤儿 tool result → 400 code 2013;纵深兜底见 m3_stream_patch)。
params = posted["session_body"]["chat_model_config"]["parameters"]
assert params["parallel_tool_calls"] is False, "fix400:必须源头禁并行 tool call"
# ② 回合未真结束(SSE 超时)→ 诚实降级(不卡死),summary 仍组出、reason 反映未结束。
async def _not_ended(*a, **k): return {"ended": False, "reason": "total_timeout", "endEvent": None}
monkeypatch.setattr(CP, "_wait_for_turn_end", _not_ended)
s2, _ = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "t9b", "brief": "点球"}))
assert s2["stoppedReason"] == "total_timeout"
def test_drive_v3_verified_reject_posts_exactly_one_repair(tmp_path, monkeypatch):
"""Service v3 不靠 RepairMiddleware;只有首轮 verified reject 才给同一 session 再发一次修复。"""
import asyncio
import httpx
import _bootstrap
import cheap_studio
import cheap_verify
import service.control_plane as CP
_install_common_stubs(tmp_path, monkeypatch)
# W-GOLD-LIVE 检查点 2:编排升 acceptance-request/3,validate.py 语义层钉死 /3 配 2026-07-15.v3
# 版本线(rolling proof-obligations.v2.json + 完整 ProofObligationRegistry schema),pin 随接线同步。
assert cheap_verify._V3_OBLIGATIONS_FILE.name == "proof-obligations.v2.json"
assert cheap_verify._V3_OBLIGATIONS_SCHEMA.name == "proof-obligation-registry.schema.json"
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3")
monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None)
verdict = {"pass": True, "guards": {name: {"pass": True} for name in cheap_verify._FLOOR_GATES}}
floor_calls = []
async def _floor(gid):
floor_calls.append(gid)
return verdict
monkeypatch.setattr(D, "_run_v3_floor_gates", _floor)
svc_rows = iter([
{"costRmb": 0.4, "rmbGate": "active", "repairs": 0},
{"costRmb": 0.2, "rmbGate": "active", "repairs": 0},
])
monkeypatch.setattr(D, "_read_service_run_summary", lambda gid, wait_s=5.0: next(svc_rows))
acceptance_requests = []
async def _accept(req):
acceptance_requests.append(req)
if len(acceptance_requests) == 1:
return {"runId": "r1", "floor": {"pass": True},
"artifactHash": "a" * 64,
"acceptanceRequestHash": req["acceptanceIdentity"]["acceptanceRequestHash"],
"decision": {"outcome": "reject", "accepted": False, "publishFrozen": True,
"repairEligible": True, "repairFeedback": "按硬证修一次",
"parentChainCostRmb": 0.3},
"compatibility": {"accepted": False, "ok": False, "acceptanceVersion": "v3",
"playtest": {}, "judge": {}, "failureLayer": {"layer": "gameplay"},
"failureReason": "硬证拒绝", "trace": {}}}
return {"runId": "r2", "floor": {"pass": True},
"artifactHash": "b" * 64,
"acceptanceRequestHash": req["acceptanceIdentity"]["acceptanceRequestHash"],
"decision": {"outcome": "accept", "accepted": True, "publishFrozen": False,
"repairEligible": False, "parentChainCostRmb": 0.5},
"compatibility": {"accepted": True, "ok": True, "acceptanceVersion": "v3",
"playtest": {"outcome": "accept"}, "judge": {"verdict": "accept"},
"failureLayer": {"layer": "none"}, "failureReason": None, "trace": {}}}
monkeypatch.setattr(cheap_verify, "run_acceptance_v3", _accept)
monkeypatch.setattr(cheap_verify, "is_v3_repair_authorized",
lambda payload: (payload.get("decision") or {}).get("repairEligible") is True)
monkeypatch.setattr(CP, "_wait_for_turn_end", lambda *a, **k: None)
async def _ended(*a, **k):
return {"ended": True, "reason": "REPLY_END", "endEvent": {}}
monkeypatch.setattr(CP, "_wait_for_turn_end", _ended)
chat_texts = []
class _Resp:
def __init__(self, data): self._data = data
def json(self): return self._data
class _Http:
def __init__(self, *a, **k): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, **kwargs):
if url.endswith("/chat/"):
chat_texts.append(kwargs["json"]["input"]["content"][0]["text"])
return _Resp({})
if url.endswith("/credential/"): return _Resp({"credential_id": "c1"})
if url.endswith("/agent/"): return _Resp({"agent_id": "a1"})
if url.endswith("/sessions/"): return _Resp({"session_id": "s1"})
return _Resp({})
async def patch(self, *a, **k): return _Resp({})
monkeypatch.setattr(httpx, "AsyncClient", _Http)
summary, _ = asyncio.run(D.drive_cheap_generation(
{"gameId": "g-v3", "traceId": "t-v3", "brief": "解谜点击"}))
assert len(chat_texts) == 2 and chat_texts[1].startswith("按硬证修一次")
assert "check → build → finish" in chat_texts[1]
assert len(floor_calls) == 2 and len(acceptance_requests) == 2
assert acceptance_requests[1]["repairCountAcrossParentChain"] == 1
assert acceptance_requests[1]["parentRunId"] == "r1"
assert acceptance_requests[0]["writerCostRmb"] == 0.4
assert acceptance_requests[1]["writerCostRmb"] == 0.2
assert all(request["acceptanceIdentity"]["interactionBinding"] is None
for request in acceptance_requests), "普通 puzzle 不得按 brief/template 自动选 Match-3"
assert all("parentChainCostRmb" not in request for request in acceptance_requests)
assert summary["acceptanceV3"]["runId"] == "r2" and summary["accepted"] is True
assert summary["acceptanceV3FirstPass"]["runId"] == "r1"
assert summary["repairAttempted"] is True and summary["acceptedAfterRepair"] is True
assert summary["attempts"] == 2 and summary["costRmb"] == 0.6
def test_explicit_match3_shadow_job_reaches_scaffold_identity_and_obligation_resolution(tmp_path, monkeypatch):
"""可信 job 显式 profile 才贯通 scaffold/identity,并在 Node 义务解析中提升有效交换义务。"""
import asyncio
import httpx
import _bootstrap
import cheap_studio
import cheap_verify
import service.control_plane as CP
_install_common_stubs(tmp_path, monkeypatch)
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3_shadow")
monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None)
captured = {"scaffold": None, "request": None}
def _scaffold(gid, template=None, interaction_profile_id=None):
captured["scaffold"] = (gid, template, interaction_profile_id)
return {"ok": True, "output": ""}
monkeypatch.setattr(cheap_run, "scaffold", _scaffold)
verdict = {"pass": True, "guards": {name: {"pass": True} for name in cheap_verify._FLOOR_GATES}}
async def _floor(_gid):
return verdict
async def _accept(request):
captured["request"] = request
return {
"runId": "match3-shadow-run", "floor": {"pass": True}, "artifactHash": "a" * 64,
"acceptanceRequestHash": request["acceptanceIdentity"]["acceptanceRequestHash"],
"decision": {"outcome": "accept", "accepted": False, "shadowAccepted": True,
"publishFrozen": True, "repairEligible": False},
"compatibility": {"accepted": False, "ok": False, "acceptanceVersion": "v3_shadow",
"playtest": {"outcome": "accept"}, "judge": {"verdict": "accept"},
"failureLayer": {"layer": "none"}, "failureReason": None, "trace": {}},
}
monkeypatch.setattr(D, "_run_v3_floor_gates", _floor)
monkeypatch.setattr(D, "_read_service_run_summary",
lambda gid, wait_s=5.0: {"costRmb": 0.2, "rmbGate": "active", "repairs": 0})
monkeypatch.setattr(cheap_verify, "run_acceptance_v3", _accept)
async def _ended(*args, **kwargs):
return {"ended": True, "reason": "REPLY_END", "endEvent": {}}
monkeypatch.setattr(CP, "_wait_for_turn_end", _ended)
class _Resp:
def __init__(self, data): self._data = data
def json(self): return self._data
class _Http:
def __init__(self, *args, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): return False
async def post(self, url, **kwargs):
if url.endswith("/credential/"): return _Resp({"credential_id": "c1"})
if url.endswith("/agent/"): return _Resp({"agent_id": "a1"})
if url.endswith("/sessions/"): return _Resp({"session_id": "s1"})
return _Resp({})
async def patch(self, *args, **kwargs): return _Resp({})
monkeypatch.setattr(httpx, "AsyncClient", _Http)
asyncio.run(D.drive_cheap_generation({
"gameId": "match3-job", "traceId": "trace-match3-job", "brief": "标准三消解谜",
"interactionProfileId": MATCH3_PROFILE_ID,
}))
assert captured["scaffold"] == ("match3-job", "_template-puzzle", MATCH3_PROFILE_ID)
identity = captured["request"]["acceptanceIdentity"]
assert identity["interactionBinding"]["interactionProfileId"] == MATCH3_PROFILE_ID
# 调真实 Node 义务解析器,证明 job 冻结的 binding 会提升 Match-3 专属有效交换义务。
core = Path(__file__).resolve().parents[2] / "game-runtime/games/_wg1-gen/_shared/playtest-v3.core.cjs"
registry = Path(__file__).resolve().parents[2] / "contracts/play-loop/proof-obligations.v2.json"
code = (
"const fs=require('fs');const c=require(process.argv[1]);"
"const r=JSON.parse(fs.readFileSync(process.argv[2],'utf8'));"
"const i=JSON.parse(process.argv[3]);"
"const o=c.resolveProofObligations(r,i.proofProfileId,{genre:i.genre,"
"templateRoute:i.templateRoute,registryVersion:i.proofRegistryVersion,"
"taskBindingHash:i.taskBindingHash,interactionBinding:i.interactionBinding});"
"process.stdout.write(JSON.stringify(o));"
)
proc = subprocess.run(
["node", "-e", code, str(core), str(registry), json.dumps(identity)],
capture_output=True, text=True, check=True,
)
obligations = json.loads(proc.stdout)
valid_swap = next(item for item in obligations["obligations"]
if item["id"] == "puzzle.match3-valid-swap")
assert obligations["interactionRuleMatches"] == ["puzzle-match3-standard-swap"]
assert valid_swap["required"] is True
def test_service_rejects_explicit_match3_profile_outside_shadow_before_scaffold(tmp_path, monkeypatch):
"""显式 interactionProfileId 只属于 v3_shadow 校准;v3 live 必须在 scaffold/Writer 前拒绝。"""
import asyncio
import cheap_studio
_install_common_stubs(tmp_path, monkeypatch)
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3")
scaffolded = []
monkeypatch.setattr(cheap_run, "scaffold", lambda *args, **kwargs: scaffolded.append(args) or {"ok": True})
summary, _ = asyncio.run(D.drive_cheap_generation({
"gameId": "match3-live", "traceId": "trace-match3-live", "brief": "标准三消解谜",
"interactionProfileId": MATCH3_PROFILE_ID,
}))
assert scaffolded == []
assert summary["ok"] is False
assert "v3_shadow" in (summary.get("stoppedReason") or summary.get("failureReason") or "")
def _install_fake_http(monkeypatch, cred=None):
"""装 fake httpx.AsyncClient:setup 三 POST 返可控体(cred 缺省给全 id),patch 空体。供回合前 unlink / fail-fast 用例复用。"""
import httpx
class _Resp:
def __init__(self, d): self._d = d
def json(self): return self._d
class _FakeHttp:
def __init__(self, *a, **k): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, **k):
if url.endswith("/credential/"):
return _Resp(cred if cred is not None else {"credential_id": "c1"})
return _Resp({"agent_id": "a1"} if url.endswith("/agent/") else
{"session_id": "sess-9"} if url.endswith("/sessions/") else {})
async def patch(self, url, **k): return _Resp({})
monkeypatch.setattr(httpx, "AsyncClient", _FakeHttp)
def _install_common_stubs(tmp_path, monkeypatch):
"""装 drive 公共桩:目录重定向到 tmp、scaffold ok、richness/key 旁路、凭据体桩(零网络/LLM)。"""
import _bootstrap
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
monkeypatch.setattr(cheap_run, "wg1_game_dir", lambda gid: tmp_path / "_wg1-gen" / gid)
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
# W-AXIS 波1:driver scaffold 前 per-run 归档(archive_prior_run 用 _ARCHIVE_DIR 全局)——沙箱到 tmp,
# 否则真驱动把预铺的 amgen-70012 移进真实仓 game-runtime/games/_amgen-archive(测试隔离泄漏)。
monkeypatch.setattr(cheap_run, "_ARCHIVE_DIR", tmp_path / "_amgen-archive")
monkeypatch.setattr(cheap_run, "scaffold", lambda gid, tpl=None: {"ok": True, "output": ""})
async def _fake_richness(gid, *, brief=""): return {"score": None, "degraded": True, "reason": "stub"}
monkeypatch.setattr("cheap_verify.verify_richness", _fake_richness)
monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None)
monkeypatch.setattr(D, "_cheap_credential_payload",
lambda user_token=None: {"data": {"type": "openai_credential", "api_key": "sk", "base_url": "http://x/v1"}})
# 有界轮询提速:sidecar 缺失时默认等 5s → 50ms(保留真实 bounded-poll,只缩窗口)。
_orig_rss = D._read_service_run_summary
monkeypatch.setattr(D, "_read_service_run_summary", lambda gid, wait_s=0.05: _orig_rss(gid, wait_s=wait_s))
def test_drive_unlinks_stale_before_turn(tmp_path, monkeypatch):
# 红线③(修 b,driver 侧):回合前清残留 verdict + service-run-summary。预铺上一局绿 verdict + 旧成本,
# fake SSE not-ended(本回合 Service 零门跑、啥也没写)→ 断言 drive 后 ok=False、costRmb=0(没被上一局绿 verdict
# 顶成假 succeeded、没读回上一局成本)。这封「零门跑路径:setup 静默失败 / SSE 超时未跑一次 check」。
import asyncio
import json
import service.control_plane as CP
_install_common_stubs(tmp_path, monkeypatch)
_install_fake_http(monkeypatch)
# 预铺上一局残留(模拟同 gameId 重跑局的陈旧盘态):绿 verdict + 旧 summary。
(tmp_path / "amgen-70012").mkdir(parents=True)
(tmp_path / "amgen-70012" / "bundle.iife.js").write_text("window.__GameBundle={};", encoding="utf-8")
vev = tmp_path / "_wg1-gen" / "70012" / "evidence"; vev.mkdir(parents=True)
(vev / "verdict.json").write_text(json.dumps({"pass": True, "guards": {"A_boot": {"pass": True}}}), encoding="utf-8")
sev = tmp_path / "amgen-70012" / "evidence"; sev.mkdir(parents=True)
(sev / "service-run-summary.json").write_text(
json.dumps({"costRmb": 0.9, "rmbGate": "active", "repairs": 3}), encoding="utf-8")
async def _not_ended(*a, **k): return {"ended": False, "reason": "total_timeout", "endEvent": None}
monkeypatch.setattr(CP, "_wait_for_turn_end", _not_ended)
summary, _ = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "tX", "brief": "点球"}))
assert summary["ok"] is False, "陈旧绿 verdict 必须已被回合前清掉,不得顶成假 succeeded"
assert summary["costRmb"] == 0.0, "陈旧 summary 也须清掉,成本不得读回上一局"
def test_drive_setup_failfast_on_missing_id(tmp_path, monkeypatch):
# #2:Service /credential 未返 id(setup 失败)→ driver fail-fast(ok=False + reason 反映 setup 失败),
# 且不进入 _wait_for_turn_end(计数坐实)——不再 id=None 硬挂 ~600s SSE 空等。
import asyncio
import service.control_plane as CP
_install_common_stubs(tmp_path, monkeypatch)
_install_fake_http(monkeypatch, cred={}) # /credential 返 {} 无 id → fail-fast
called = {"wait": 0}
async def _counting_wait(*a, **k):
called["wait"] += 1
return {"ended": True, "reason": "REPLY_END"}
monkeypatch.setattr(CP, "_wait_for_turn_end", _counting_wait)
summary, _ = asyncio.run(D.drive_cheap_generation({"gameId": 70012, "traceId": "tf", "brief": "b"}))
assert summary["ok"] is False
assert "setup" in (summary.get("stoppedReason") or ""), "reason 应反映 setup 失败"
assert called["wait"] == 0, "setup 失败必须 fail-fast,不进入 _wait_for_turn_end 空等"
def test_worker_default_run_fn_is_service_driver():
# worker_service 默认 run_fn 已切到驱动 Service(不再进程内 run_studio)。
state = W.WorkerState()
assert state.run_fn is W._service_run_fn