games-development-ai/cheap-worker/tests/test_cheap_service_app.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

266 lines
14 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_app 单测:import 惰性红线 + 会话注册表读兜底/映射解析/I1 fail-closed + 端口池派生 + 工厂结构/soft/三闸=150 + collector REPLY_END flush。
真起 Service(Redis)、真跑门(chrome)排 mini-desktop 窗口;本测只验装配零件与惰性,零网络/LLM/chrome。
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_service_app.py -v
"""
import asyncio
import sys
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_app as A # noqa: E402
def test_read_session_cfg_missing_returns_empty(tmp_path, monkeypatch):
# 注册表不存在 → {}(工厂据此回落 game_id=session_id;正常路 driver 恒在 /chat 前写、不会缺)。
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
assert A._read_session_cfg("s1") == {}
def test_read_session_cfg_reads_mapping_and_whitelist(tmp_path, monkeypatch):
# 读会话注册表:external_game_id(后端 gameId)+ write_whitelist + scaffold_template + restricted。
import json
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
p = tmp_path / "_cheap-sessions" / "s2.json"
p.parent.mkdir(parents=True)
p.write_text(json.dumps({"external_game_id": "70012", "write_whitelist": ["game-logic.js"],
"scaffold_template": "_template-shop", "restricted": True}), encoding="utf-8")
cfg = A._read_session_cfg("s2")
assert cfg["external_game_id"] == "70012"
assert cfg["write_whitelist"] == ["game-logic.js"]
assert cfg["scaffold_template"] == "_template-shop"
assert cfg["restricted"] is True
def test_resolve_external_game_id_falls_back_to_session(tmp_path, monkeypatch):
# sidecar 缺 external_game_id → 工厂回落 session_id(响亮失败,不静默用错目录)。
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
assert A._resolve_external_game_id("sX", {}) == "sX"
assert A._resolve_external_game_id("sX", {"external_game_id": "70099"}) == "70099"
def test_port_pool_derives_distinct_pairs():
# 端口池派生不同端口对、释放后可再取(并发避撞的机制核)。
p1 = A.acquire_ports()
p2 = A.acquire_ports()
assert p1 != p2
assert p1[0] >= A._CHEAP_PORT_BASE and p1[1] >= A._CHEAP_CDP_BASE
A.release_ports(p1)
A.release_ports(p2)
p3 = A.acquire_ports() # 释放后能再取到(不泄漏)
assert p3 in (p1, p2)
A.release_ports(p3)
def test_tools_factory_returns_seven_tools_bound_to_external_game_id(tmp_path, monkeypatch):
import json
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
p = tmp_path / "_cheap-sessions" / "s1.json"
p.parent.mkdir(parents=True)
p.write_text(json.dumps({"external_game_id": "70012"}), encoding="utf-8") # create 路:仅映射
tools = asyncio.run(A._cheap_tools_factory("u1", "a1", "s1"))
# 便宜档七工具(read/list/write/edit/check/build/finish);edit_file 是 fix①-A(2026-07-08)加的增量改工具。
assert len(tools) == 7, "便宜档七工具(read/list/write/edit/check/build/finish)"
def test_resolve_write_whitelist_i1_fail_closed():
# I1 纯函数:create 路(非 restricted、无白名单)→ None(不收窄,全 L3 自由写);
# reskin 路(restricted:true + 白名单)→ 收窄成 set;restricted 但白名单缺失 → 空集(fail-closed 禁写、不回落 None)。
assert A._resolve_write_whitelist({}) is None
assert A._resolve_write_whitelist({"restricted": False}) is None
assert A._resolve_write_whitelist({"restricted": True, "write_whitelist": ["game-logic.js"]}) == {"game-logic.js"}
assert A._resolve_write_whitelist({"restricted": True}) == set(), "restricted 但缺白名单 → 空集(禁写)"
assert A._resolve_write_whitelist({"restricted": True, "write_whitelist": []}) == set()
def test_middlewares_factory_soft_budget_and_failsafe_caps(tmp_path, monkeypatch):
import json
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
p = tmp_path / "_cheap-sessions" / "s2.json"
p.parent.mkdir(parents=True)
p.write_text(json.dumps({"external_game_id": "70020"}), encoding="utf-8")
mws = asyncio.run(A._cheap_middlewares_factory("u1", "a1", "s2"))
names = [type(m).__name__ for m in mws]
# v3 下旧 RepairMiddleware 必须摘除;真相层仍追加在末位,check/build 由工具与 ReAct 保留。
assert names == ["_CheapRunCollector", "Tier2TraceMiddleware",
"CircuitBreakerMiddleware", "CheapTurnsMiddleware"]
breaker = mws[2]
assert breaker.soft_budget is True, "决策①:cheap 软预算切 soft"
assert breaker.rmb_hard_limit == 10.0, "cheap ¥10(soft 档软目标,不再是硬地板)"
# 决策① + Codex C4:成本上界靠 max_repairs=6;三道次数/轮数闸抬到 150 当纯失控兜底(非成本尺)。
assert breaker.max_model_calls == 150, "失控兜底 150(非成本尺)"
assert breaker.max_tool_calls == 150, "C4:必须显式设 150,否则默认 60 会先撞"
assert breaker.step_timeout_s >= 390, "C3:单步静默阈值须 > 单次门跑最坏(~390s)"
assert breaker.enable_rmb_gate is True
collector = mws[0]
assert collector._game_id == "70020", "C2:collector 绑后端 gameId(经 sidecar 解析),非 session_id"
assert collector._repair is None, "v3 的 gameplay 修复权只来自 final verified reject"
def test_middlewares_factory_keeps_repair_only_for_legacy_mode(tmp_path, monkeypatch):
"""历史 v1/v2 回放仍可装旧 RepairMiddleware,但 v3 默认路径绝不装。"""
import json
from worker import genconfig
monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: tmp_path / "_cheap-sessions" / f"{sid}.json")
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
p = tmp_path / "_cheap-sessions" / "legacy.json"
p.parent.mkdir(parents=True)
p.write_text(json.dumps({"external_game_id": "70022"}), encoding="utf-8")
original_get = genconfig.get
monkeypatch.setattr(genconfig, "get", lambda area, key, default=None:
"v2" if (area, key) == ("acceptance", "mode") else original_get(area, key, default))
mws = asyncio.run(A._cheap_middlewares_factory("u1", "a1", "legacy"))
names = [type(m).__name__ for m in mws]
assert names[2] == "RepairMiddleware"
breaker = mws[3]
repair = mws[2]
assert repair._max_repairs == 6 # genconfig iteration.max_resumes(成本上界=优雅终止)
# budget_exhausted 读实测已花 ¥ 超上限(非预估):未超 False、超 True。
breaker._rmb_gate_active = True
breaker.spent_rmb = 0.0
assert repair._budget_exhausted() is False
breaker.spent_rmb = 10.5
assert repair._budget_exhausted() is True
def test_collector_flushes_on_reply_end(tmp_path, monkeypatch):
# C1:collector 在 REPLY_END 流经时**同步 flush**(不等 finally),关闭「driver 读 REPLY_END 时 sidecar 还空」竞态。
import json
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
class _FakeBreaker:
spent_rmb, _rmb_gate_active, budget_soft_tripped = 0.42, True, False
class _FakeRepair:
repairs = 2
class _FakeTracer:
def summary(self):
return {"steps": 3}
class ReplyEndEvent: # 类名即判据:collector 认 type(evt).__name__ == "ReplyEndEvent"(镜像框架事件类名)
pass
Collector = A._get_collector_cls()
col = Collector(game_id="70021", breaker=_FakeBreaker(), repair=_FakeRepair(), tracer=_FakeTracer())
async def _fake_next(**_):
yield ReplyEndEvent() # REPLY_END 先于 finish Msg 流经(_agent.py:615)
async def _drive():
seen_after = None
async for evt in col.on_reply(None, {}, _fake_next):
# yield REPLY_END 之前 flush 必已发生(同步、无 await)→ 此刻 sidecar 已在盘上
seen_after = (tmp_path / "amgen-70021" / "evidence" / "service-run-summary.json").exists()
return seen_after
assert asyncio.run(_drive()) is True, "REPLY_END 流经时 sidecar 应已落盘(同步 flush)"
summary = json.loads((tmp_path / "amgen-70021" / "evidence" / "service-run-summary.json").read_text(encoding="utf-8"))
assert summary["costRmb"] == 0.42 and summary["repairs"] == 2
def test_reply_end_event_class_name_canary():
# collector 用 type(evt).__name__ == "ReplyEndEvent" 字符串判(cheap_service_app.py on_reply)锁 REPLY_END 落盘时机;
# 此 canary 钉住 AgentScope 事件类真名:2.0.x 若改名 ReplyEndEvent,字符串判会静默退化成 finally-only flush
# (成功局 costRmb=0),本断言让改名即红、把静默退化变永久绊线。生产码保留字符串判(不引硬 import 依赖、保 6c6g 惰性)。
from agentscope.event import ReplyEndEvent # 已实测可 import
assert ReplyEndEvent.__name__ == "ReplyEndEvent"
def _make_collector(A_mod, game_id, tmp_path, monkeypatch):
# 工单 f 测试脚手架:mock game_dir 到 tmp,造一个绑好 fake breaker/repair/tracer 的 collector。
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / f"amgen-{gid}")
class _FakeBreaker:
spent_rmb, _rmb_gate_active, budget_soft_tripped = 0.5, True, False
class _FakeRepair:
repairs = 1
class _FakeTracer:
def summary(self):
return {"steps": 2}
Collector = A_mod._get_collector_cls()
return Collector(game_id=game_id, breaker=_FakeBreaker(), repair=_FakeRepair(), tracer=_FakeTracer())
def test_flush_prints_landing_line_once_idempotent(tmp_path, monkeypatch, capsys):
# 工单 f:_flush 在 REPLY_END / except / finally 三条降级路会被调 ≥2 次,落盘每次覆盖是幂等的(语义不变),
# 但「收口采集落盘」成功行只应打一行——重复打印纯观感噪声。此测直接连调三次 _flush,钉住「只打一行」。
import json
col = _make_collector(A, "70055", tmp_path, monkeypatch)
col._flush()
col._flush()
col._flush() # 三次(模拟 REPLY_END + except + finally 最坏叠加)
out = capsys.readouterr().out
assert out.count("收口采集落盘") == 1, "多次 _flush 只打一行收口采集落盘(幂等打印,工单 f)"
# 采集本身幂等语义不变:多次调用后 sidecar 仍在盘上、内容为最新(costRmb/repairs 可读)。
summary = json.loads((tmp_path / "amgen-70055" / "evidence" / "service-run-summary.json").read_text(encoding="utf-8"))
assert summary["costRmb"] == 0.5 and summary["repairs"] == 1
def test_flush_landing_line_once_on_crash_path(tmp_path, monkeypatch, capsys):
# 工单 f 端到端:on_reply 崩溃路(next_handler 抛异常)会走 except 支 _flush + finally _flush 两次落盘,
# 修前打两行「收口采集落盘」,修后只打一行;合成 ReplyEndEvent 分支不产成功行、不干扰计数。
col = _make_collector(A, "70056", tmp_path, monkeypatch)
class _FakeState:
session_id, reply_id = "s1", "r1"
class _FakeAgent:
state = _FakeState()
async def _boom(**_):
raise RuntimeError("run 崩(压缩/熔断模拟)")
yield # 使其成为 async generator(永不到达)
async def _drive():
try:
async for _evt in col.on_reply(_FakeAgent(), {}, _boom):
pass # 合成 ReplyEndEvent 流经,不做处理
except RuntimeError:
pass # 原异常按设计重抛,测试吞掉(不遮真失败)
asyncio.run(_drive())
out = capsys.readouterr().out
assert out.count("收口采集落盘") == 1, "崩溃路两次 _flush 也只打一行收口采集落盘(工单 f)"
def test_collector_sniffs_tokens_and_emits_metric_once_cached_none(tmp_path, monkeypatch):
"""面二 Service 路 wiring:collector 只读嗅探 ModelCallEndEvent 累加 in/out,REPLY_END 收口发一次 metric;
cached 传 None(生产 Service 路框架默认 model 无 records、拿不到 cached,如实降级);_flush 多调 metric 只发一次。"""
col = _make_collector(A, "70066", tmp_path, monkeypatch)
# monkeypatch recorder:只记被调参数(record 本体的默认关/best-effort/降级已在 test_cheap_otlp_sink 覆盖,这里只验 wiring)。
import cheap_otlp_sink
calls = []
monkeypatch.setattr(cheap_otlp_sink, "record_llm_token_metrics",
lambda i, o, c=None, **k: calls.append((i, o, c)))
class ModelCallEndEvent: # 类名即判据(collector 认 type(evt).__name__ == "ModelCallEndEvent")
def __init__(self, i, o):
self.input_tokens, self.output_tokens = i, o
class ReplyEndEvent:
pass
async def _fake_next(**_):
yield ModelCallEndEvent(1200, 800) # 第一次模型调用
yield ModelCallEndEvent(300, 100) # 第二次
yield ReplyEndEvent() # 收口(触发 _flush → _emit_metrics)
async def _drive():
async for _evt in col.on_reply(None, {}, _fake_next):
pass
asyncio.run(_drive())
assert col._tok_in == 1500 and col._tok_out == 900, "嗅探累加 in/out(1200+300 / 800+100)"
# on_reply 内 REPLY_END + finally 已两调 _flush;再手调两次(模拟 except/finally 最坏叠加),metric 仍只发一次。
col._flush()
col._flush()
assert calls == [(1500, 900, None)], "收口只发一次 metric;cached=None(生产 Service 路降级、不伪造命中率)"