便宜档生成切到 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>
309 lines
12 KiB
Python
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)
|