games-development-ai/cheap-worker/tests/test_worker_service.py
lili d00938ccbd feat(cheap-worker): M3b 本机 U1-U3 — 9d trace parity 救活 D11 + D9 vendored + 跨语言反假绿门(切片一 M3b)
便宜档生成切到 cheap-worker 新路后,result_out 只产 trace.cost、缺 9d trace 七项,致后端 D11 ReadinessScorer 三维恒中性、退化成 45/55 双峰近恒定分,D9 反同质失效。

U1 cheap_studio 富化:build_trace_source + _furthest_stage,additive 捕获 verdictFull/driverType/models/stage。U2 result_out._build_trace:七项(repairs=max(0,attempts-1))+ sevenGateVerdict{pass,guards} + gatespec{driver} + cost + similarity,camelCase 镜像 _extract_trace、None 容错。U3 D9 dedup vendored 进 cheap-worker/dedup.py(registry 指自己 results/)、worker_service 接线非阻断。

Java 跨语言切片测加逐维反假绿门(反射调真 ReadinessScorer:三维脱离中性 + 合成分 100 vs 退化 ≤55 拉开 ≥35)。顺带修预存 M3a 红测:worker 异常兜底 failureReason 改传真枚举 llm_error。

验证:cheap-worker 全 16 测绿 + Java 切片 5/5 绿 BUILD SUCCESS。范围仅 cheap-worker/ + 2 fixture + 1 Java 测 + plan,零 live 触碰。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 02:17:41 -07:00

309 lines
12 KiB
Python

"""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
# ---------- 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
# ---------- HTTP 集成(起真服务器 + worker 线程,注入 stub run_fn/send_fn)----------
def test_http_e2e_accept_and_process():
"""POST /generate → 202 投递握手 → worker 串行处理 → 向 callback 发 succeeded result-out。"""
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_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"
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)