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

429 lines
17 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.

"""test_worker_service.py — §6.1 HTTP worker 核心逻辑单测(M3a U1)。
守的不变量:
· job-in 解析:合法 JSON → dict;坏 JSON → None(不抛)。
· HMAC:compute_signature == hmac-sha256(secret, raw bytes) hex 小写(与 Java CallbackSignatureVerifier 对账)。
· 有界队列 + 去重(KTD4):有容量 → 202 受理入队;队满 → 503(非 2xx,执行器据此 LLM_ERROR,诚实残留);
同 job_id 重投 → 202 幂等、不重复入队(worker 侧防同 job 重跑)。
· process_job:跑 run_fn → 组 result-out → 向 callback.target 发(注入 stub run_fn/send_fn,零真实网络/LLM)。
跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_worker_service.py
"""
import hashlib
import hmac
import json
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import worker_service as W # noqa: E402
import dedup # noqa: E402
# M3b U3:process_job 现会调 D9 dedup.check_similarity(写 FS 登记表)。把登记表重定向到临时路径,
# 防本测污染真 cheap-worker/results/_dedup_registry.jsonl(与 wg1_groupb_test 隔离范式一致)。
dedup.DEDUP_REGISTRY = Path(tempfile.mkdtemp(prefix="ws-dedup-")) / "_dedup_registry.jsonl"
_GOOD_BUNDLE = "var __GameBundle=(function(){return{bootGameHost(){}}})();"
def _tmp() -> Path:
return Path(tempfile.mkdtemp(prefix="ws-test-"))
def _make_game_dir(tmp: Path) -> Path:
gd = tmp / "amgen-w1"
(gd / "src").mkdir(parents=True)
(gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8")
(gd / "src" / "game-logic.js").write_text("// l\n", encoding="utf-8")
return gd
# ---------- parse_job ----------
def test_parse_job_valid():
raw = json.dumps({"job_id": "j1", "brief": "b", "gameId": "g1",
"callback": {"target": "http://x"}}).encode("utf-8")
job = W.parse_job(raw)
assert job["job_id"] == "j1" and job["gameId"] == "g1"
def test_parse_job_bad_json_returns_none():
assert W.parse_job(b"{not json") is None
def test_default_run_fn_forwards_explicit_interaction_profile(monkeypatch, tmp_path):
"""进程内兼容入口必须原样透传可信 job 的 profile;字段缺失时仍传 None,不按 brief 猜测。"""
import cheap_run
import cheap_studio
received = []
async def _run_studio(game_id, brief, **kwargs):
received.append((game_id, brief, kwargs))
return {"ok": False}
monkeypatch.setattr(cheap_studio, "run_studio", _run_studio)
monkeypatch.setattr(cheap_run, "game_dir", lambda gid: tmp_path / str(gid))
W._default_run_fn({
"gameId": 42, "traceId": "trace-42", "brief": "标准三消解谜",
"interactionProfileId": "match3.orthogonal-swap-v1",
})
W._default_run_fn({"gameId": 43, "traceId": "trace-43", "brief": "标准三消解谜"})
assert received[0][2]["interaction_profile_id"] == "match3.orthogonal-swap-v1"
assert received[1][2]["interaction_profile_id"] is None
# ---------- HMAC ----------
def test_compute_signature_matches_hmac_sha256():
sig = W.compute_signature("secret", b"data")
assert sig == hmac.new(b"secret", b"data", hashlib.sha256).hexdigest()
def test_compute_signature_empty_secret_none():
"""空 secret → 不签(返 None),与 service.py 一致(密钥空=验签关闭)。"""
assert W.compute_signature("", b"data") is None
# ---------- 有界队列 + 去重 ----------
def test_enqueue_accepts_until_full_then_503():
state = W.WorkerState(queue_maxsize=2, run_fn=lambda job: (None, None),
send_fn=lambda *a: (200, ""))
assert W.try_enqueue(state, {"job_id": "a"}) == (True, 202)
assert W.try_enqueue(state, {"job_id": "b"}) == (True, 202)
# 队满:503(非 2xx;诚实残留:执行器据此置 LLM_ERROR,见 KTD4/m1)
assert W.try_enqueue(state, {"job_id": "c"}) == (False, 503)
def test_enqueue_dedup_same_job_id():
state = W.WorkerState(queue_maxsize=8, run_fn=lambda job: (None, None),
send_fn=lambda *a: (200, ""))
assert W.try_enqueue(state, {"job_id": "a"}) == (True, 202)
# 同 job_id 重投:幂等 202、不重复入队
assert W.try_enqueue(state, {"job_id": "a"}) == (True, 202)
assert state.queue.qsize() == 1
# ---------- process_job(注入 stub,零网络/LLM)----------
def test_process_job_success_posts_result_out_to_callback():
gd = _make_game_dir(_tmp())
captured = {}
def send_fn(url, payload, secret):
captured["url"] = url
captured["payload"] = payload
captured["secret"] = secret
return 200, "ok"
state = W.WorkerState(
callback_secret="s3cr3t",
run_fn=lambda job: ({"verdict": {"pass": True}, "costRmb": 0.02}, gd),
send_fn=send_fn,
profile_fn=lambda job, game_dir: None, # best-effort:本测不产 sourceProject
)
job = {"job_id": "j", "traceId": "j", "templateId": "generic", "brief": "点点乐",
"callback": {"target": "http://cb/internal"}}
W.process_job(state, job)
assert captured["url"] == "http://cb/internal"
assert captured["payload"]["status"] == "succeeded"
assert "__GameBundle" in captured["payload"]["engineBundle"]
assert captured["payload"]["gameConfig"]["engineDriven"] is True
assert captured["secret"] == "s3cr3t"
def test_process_job_failed_maps_failure_reason():
gd = _make_game_dir(_tmp())
captured = {}
state = W.WorkerState(
callback_secret="",
run_fn=lambda job: ({"verdict": {"pass": False, "failedGates": ["E_live"]}}, gd),
send_fn=lambda url, payload, secret: captured.update(payload=payload) or (200, ""),
profile_fn=lambda job, game_dir: None,
)
job = {"job_id": "j2", "templateId": "generic", "brief": "x",
"callback": {"target": "http://cb"}}
W.process_job(state, job)
assert captured["payload"]["status"] == "failed"
assert captured["payload"]["failureReason"] in {
"no_template_match", "config_invalid", "content_violation",
"budget_exceeded", "timeout", "llm_error", "generation_failed",
}
assert captured["payload"].get("engineBundle") is None
def test_process_job_with_profile_includes_source_project():
gd = _make_game_dir(_tmp())
captured = {}
profile = {"tickModel": "frame", "inputModel": "tap-targets", "progressModel": "score"}
state = W.WorkerState(
callback_secret="",
run_fn=lambda job: ({"verdict": {"pass": True}}, gd),
send_fn=lambda url, payload, secret: captured.update(payload=payload) or (200, ""),
profile_fn=lambda job, game_dir: profile,
)
job = {"job_id": "j3", "templateId": "generic", "brief": "x",
"callback": {"target": "http://cb"}}
W.process_job(state, job)
sp = json.loads(captured["payload"]["sourceProject"])
assert sp["schemaVersion"] == "2.0" and sp["profile"] == profile
def test_create_scaffold_lineage_only_comes_from_publishable_v3(monkeypatch):
"""worker 只消费权威发布谓词;冻结 v2 和未验证对象都不得产血缘。"""
sample = (Path(__file__).resolve().parents[2] / "contracts/play-loop/samples/playtest-evidence/valid"
/ "01-accepted-narrative.json")
frozen_v2 = json.loads(sample.read_text(encoding="utf-8"))
assert W._accepted_scaffold_template({"acceptanceV3": frozen_v2}) is None
assert W._accepted_scaffold_template({
"acceptanceV3": {"templateRoute": "_template-story", "decision": {"accepted": True}}
}) is None
# 完整 playtest/3 契约与发布谓词由 test_acceptance_v3 覆盖;这里只验证 worker 的路由消费边界。
publishable_v3 = {"schemaVersion": "playtest/3", "templateRoute": "_template-story"}
monkeypatch.setattr(W.cheap_verify, "is_v3_publishable", lambda value: value is publishable_v3)
assert W._accepted_scaffold_template({"acceptanceV3": publishable_v3}) == "_template-story"
# ---------- HTTP 集成(起真服务器 + worker 线程,注入 stub run_fn/send_fn)----------
def test_http_e2e_accept_and_process(monkeypatch):
"""POST /generate → 202 投递握手 → worker 串行处理 → 向 callback 发 succeeded result-out。"""
monkeypatch.setattr(W.cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v2"})
gd = _make_game_dir(_tmp())
captured = {}
state = W.WorkerState(
run_fn=lambda job: ({"verdict": {"pass": True}}, gd),
send_fn=lambda url, payload, secret: captured.update(payload=payload) or (200, ""),
profile_fn=lambda j, g: None,
)
W.start_worker(state)
server, port = W.start_server(state, port=0)
try:
body = json.dumps({"job_id": "h1", "traceId": "h1", "templateId": "generic",
"brief": "x", "gameId": "w1", "callback": {"target": "http://cb"}}).encode("utf-8")
req = urllib.request.Request(f"http://127.0.0.1:{port}/generate", data=body,
method="POST", headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5) as resp:
assert resp.status == 202
ack = json.loads(resp.read())
assert ack["accepted"] is True and ack["traceId"] == "h1"
state.queue.join() # 等 worker 处理完
assert captured["payload"]["status"] == "succeeded"
assert "__GameBundle" in captured["payload"]["engineBundle"]
finally:
server.shutdown()
def test_http_freezes_expected_v3_and_blocks_fully_stripped_summary(monkeypatch):
"""HTTP 入队冻结 v3;run_fn 即使只返 legacy pass=true,也必须 downgrade failed。"""
monkeypatch.setattr(W.cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v3"})
gd = _make_game_dir(_tmp())
captured = {}
state = W.WorkerState(
run_fn=lambda job: (captured.update(job=job) or {"verdict": {"pass": True}, "accepted": True}, gd),
send_fn=lambda url, payload, secret: captured.update(payload=payload) or (200, ""),
profile_fn=lambda j, g: None,
)
W.start_worker(state)
server, port = W.start_server(state, port=0)
try:
body = json.dumps({"job_id": "h-v3", "traceId": "h-v3", "templateId": "generic",
"brief": "x", "gameId": "w-v3",
"callback": {"target": "http://cb"}}).encode("utf-8")
req = urllib.request.Request(f"http://127.0.0.1:{port}/generate", data=body,
method="POST", headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5) as resp:
assert resp.status == 202
state.queue.join()
assert captured["job"]["_expectedAcceptanceMode"] == "v3"
assert captured["payload"]["status"] == "failed"
assert captured["payload"]["trace"]["acceptanceState"] == "downgrade_detected"
finally:
server.shutdown()
def test_http_bad_json_returns_400():
state = W.WorkerState(run_fn=lambda j: (None, None), send_fn=lambda *a: (200, ""))
server, port = W.start_server(state, port=0)
try:
req = urllib.request.Request(f"http://127.0.0.1:{port}/generate", data=b"{bad",
method="POST", headers={"Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=5)
raise AssertionError("应回 400")
except urllib.error.HTTPError as e:
assert e.code == 400
finally:
server.shutdown()
def test_http_health_200():
state = W.WorkerState(run_fn=lambda j: (None, None), send_fn=lambda *a: (200, ""))
server, port = W.start_server(state, port=0)
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as resp:
assert resp.status == 200
assert json.loads(resp.read())["ok"] is True
finally:
server.shutdown()
def test_post_callback_builds_signed_request():
"""post_callback 构建 POST 请求:body=签名字节、X-Callback-Signature=HMAC(secret,body)(与 Java 对账)。"""
captured = {}
class FakeResp:
status = 200
def read(self):
return b"ok"
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fake_opener(req, timeout=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["body"] = req.data
captured["headers"] = {k.lower(): v for k, v in req.headers.items()}
return FakeResp()
status, body = W.post_callback("http://cb/internal", {"traceId": "t", "status": "succeeded"},
"sek", opener=fake_opener)
assert status == 200 and body == "ok"
assert captured["method"] == "POST"
assert captured["url"] == "http://cb/internal"
assert captured["headers"]["content-type"].startswith("application/json")
expected_sig = hmac.new(b"sek", captured["body"], hashlib.sha256).hexdigest()
assert captured["headers"]["x-callback-signature"] == expected_sig
assert json.loads(captured["body"])["traceId"] == "t"
def test_post_callback_no_signature_when_secret_empty():
"""空 secret → 不带签名头(与 service.py 一致)。"""
captured = {}
class FakeResp:
status = 200
def read(self):
return b""
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fake_opener(req, timeout=None):
captured["headers"] = {k.lower(): v for k, v in req.headers.items()}
return FakeResp()
W.post_callback("http://cb", {"x": 1}, "", opener=fake_opener)
assert "x-callback-signature" not in captured["headers"]
def test_worker_loop_run_failure_sends_fallback_failed():
"""run_fn 抛异常 → worker 发兜底 failed 回调(任务不挂 RUNNING)。"""
captured = {}
def boom(job):
raise RuntimeError("gen exploded")
state = W.WorkerState(
run_fn=boom,
send_fn=lambda url, payload, secret: captured.update(payload=payload) or (200, ""),
)
W.start_worker(state)
W.try_enqueue(state, {"job_id": "boom1", "traceId": "boom1", "templateId": "generic",
"callback": {"target": "http://cb"}})
state.queue.join()
assert captured["payload"]["status"] == "failed"
# 七值枚举 catch-all:worker 异常兜底 → failureReason=llm_error(M3a 七值收敛后的真值;
# 原断言 generation_failed 是 M3a 遗留陈旧值 —— 非枚举值会被后端 isValidFailureReason 拒)。
assert captured["payload"]["failureReason"] == "llm_error"
# ---------- 面四断点①(capture):do_POST 抓入站 traceparent 进 job ----------
def test_do_post_captures_inbound_traceparent_into_job():
"""POST /generate 带 traceparent header → worker 线程拿到的 job 带 _otelCarrier(供建 worker span)。"""
gd = _make_game_dir(_tmp())
captured = {}
state = W.WorkerState(
run_fn=lambda job: captured.update(job=job) or ({"verdict": {"pass": True}}, gd),
send_fn=lambda url, payload, secret: (200, ""),
profile_fn=lambda j, g: None,
)
W.start_worker(state)
server, port = W.start_server(state, port=0)
try:
tp = "00-11112222333344445555666677778888-1111222233334444-01"
body = json.dumps({"job_id": "tp1", "traceId": "tp1", "templateId": "generic", "brief": "x",
"gameId": "w1", "callback": {"target": "http://cb"}}).encode("utf-8")
req = urllib.request.Request(f"http://127.0.0.1:{port}/generate", data=body, method="POST",
headers={"Content-Type": "application/json", "traceparent": tp})
with urllib.request.urlopen(req, timeout=5) as resp:
assert resp.status == 202
state.queue.join()
# 入站 traceparent 被抓进 job(do_POST 只这里能读 header),供 process_job 在 worker 线程建 worker span。
assert captured["job"]["_otelCarrier"]["traceparent"] == tp
finally:
server.shutdown()
def test_do_post_without_traceparent_adds_no_carrier():
"""无 traceparent header → job 不带 _otelCarrier(纯增量、不无中生有;默认关行为不变)。"""
gd = _make_game_dir(_tmp())
captured = {}
state = W.WorkerState(
run_fn=lambda job: captured.update(job=job) or ({"verdict": {"pass": True}}, gd),
send_fn=lambda url, payload, secret: (200, ""),
profile_fn=lambda j, g: None,
)
W.start_worker(state)
server, port = W.start_server(state, port=0)
try:
body = json.dumps({"job_id": "n1", "traceId": "n1", "brief": "x", "gameId": "w1",
"callback": {"target": "http://cb"}}).encode("utf-8")
req = urllib.request.Request(f"http://127.0.0.1:{port}/generate", data=body, method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5) as resp:
assert resp.status == 202
state.queue.join()
assert "_otelCarrier" not in captured["job"]
finally:
server.shutdown()
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{len(_fns) - _failed}/{len(_fns)} passed")
sys.exit(1 if _failed else 0)