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