固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。 将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。 同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
549 lines
32 KiB
Python
549 lines
32 KiB
Python
"""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
|