games-development-ai/cheap-worker/tests/test_gameplay_judge.py
lili 882ca3f357 feat(cheap-gen): W-AXIS 波2 判定语义重置——独立模型玩法判定阻断+拆残笼+判定档切 M3
裁定三落地:accepted = 机械预筛 ∧ 独立模型玩法判定,九门 pass 降为「未见明显死」预筛。

- 波2a 玩法判定器(cheap_verify.py):读 brief+run-summary+全程截图(含新增局中连拍
  midplay-1..N,play.cdp.cjs 只读并发拍、不进任何门判据),布尔裁 broken/hollow/off_brief,
  fail-closed;判定真相层落 evidence/judge.json + verdict.json 写回 accepted/judge 段;
  金标夹具 fixtures/judge-golden/ + judge_golden.py 校准跑批
- 波2b 拆残笼:check 词法 tokenizer 替换 stripCode 正则(字符串内 // 假阴根修,292 在册
  源零差异);prompt v1.6.3 外科清洗(两层奖励→纯设计语/删盲驱动器围设计/删熔断恐吓),
  registry 热取==内置逐字节一致(cheap_roles 内置回退同步);edit_file 空白归一+近似片段提示
- 判定器补修:空输出有界重试一次(glm 思考吃光 max_tokens 时 content 空)+重试放大公式
  反缩 bug 修(旧 min(×2,6000) 大 base 反缩)+finishReason 落盘;imagesSeen 模型自报+
  盲检微扰重掷、两掷均盲=仪器故障 degraded(闲鱼中转静默丢图实锤,不再错杀 hollow)
- 判定档切 MiniMax-M3(创始人 2026-07-10 拍板,裁定三①边界随 v2 plan 修订):
  generation.yaml judge.model+max_tokens=202752(512K 网关实探 400 拒,取实探上限);
  3 例盲案 M3 重判全 accept、¥0.026/局
- 三路消费对齐:CLI 预筛语义修(cheap_studio 喂 verdict.pass 而非 finished,违裁定三的
  放行已纠)/Service 路 apply_gameplay_judge 显式 prefilter 参数/result_out 权威改读
  accepted(无键回落预筛,旧产物兼容)+trace.gameplayJudge additive 透传;
  tier2 gate_judge/genconfig 同步判定配置与消费

测试:test_gameplay_judge 33 项新增,全仓 pytest 480 绿;真判定闭环 accept ¥0.061。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:27:18 -07:00

453 lines
21 KiB
Python

"""
test_gameplay_judge.py — W-AXIS 波2 独立模型玩法地板判定单测(schema / fail-closed / ok 语义切换 / 金标读取器)。
三层,全部零真网络(fake model 注入 + 注入证据 + tmp 证据目录):
① parse_floor_judgment 纯函数:accept/reject 三类解析、LLM verdict 覆盖、裸 bool 容忍、缺字段/坏 JSON → fail-closed degraded。
② judge_gameplay_floor 契约:无证据 → fail-closed;fake 模型 accept/异常/超时 → 结构正确;成本记账字段。
③ apply_gameplay_judge ok 语义:预筛未过不跑判定;accept∧blocking→ok=accepted;reject/degraded∧blocking→fail-closed;
blocking=false→观测不阻断(ok=预筛)。外加金标读取器 load_golden(五份待标注种子)与 _collect_frames 排序/采样。
跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_gameplay_judge.py -q
或:cheap-worker/.venv/bin/python cheap-worker/tests/test_gameplay_judge.py
"""
import asyncio
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_verify # noqa: E402
import judge_golden # noqa: E402
# ── fake 判定模型(async 可调用;默认无 usage_sum,使 judge 的成本块走 except、零网络)──
class _FakeResp:
def __init__(self, text):
self.content = text
class _FakeJudge:
"""返回固定 JSON / 抛异常 / 睡眠(测超时)。默认不带 usage_sum(成本块 AttributeError→跳过,零网络)。"""
def __init__(self, text="", raise_exc=None, sleep=0.0):
self._text, self._raise, self._sleep = text, raise_exc, sleep
async def __call__(self, messages, **kw):
if self._sleep:
await asyncio.sleep(self._sleep)
if self._raise:
raise self._raise
return _FakeResp(self._text)
class _FakeJudgeWithUsage(_FakeJudge):
"""带 usage_sum + records 的 fake(测成本记账字段流转)。"""
records = [(1000, 200, 100)]
def usage_sum(self):
return (1000, 200)
def _accept_json():
return json.dumps({"broken": {"problem": False, "why": "能玩通"},
"hollow": {"problem": False, "why": "有决策"},
"offBrief": {"problem": False, "why": "切题"},
"verdict": "accept", "notes": "地板成立"}, ensure_ascii=False)
def _reject_json(cls="hollow"):
node = {"broken": {"problem": False, "why": "x"},
"hollow": {"problem": False, "why": "x"},
"offBrief": {"problem": False, "why": "x"}}
key = {"broken": "broken", "hollow": "hollow", "off_brief": "offBrief"}[cls]
node[key] = {"problem": True, "why": "有问题"}
node["verdict"] = "reject"
node["notes"] = "地板不成立"
return json.dumps(node, ensure_ascii=False)
def _tmp_evidence(with_first=True, mids=0, with_after=True):
"""建 tmp 证据目录 + 写占位 png(内容随意,_collect_frames 只 base64 字节)。返回目录路径。"""
d = Path(tempfile.mkdtemp())
if with_first:
(d / "first-paint.png").write_bytes(b"\x89PNG-first")
for i in range(1, mids + 1):
(d / f"midplay-{i}.png").write_bytes(b"\x89PNG-mid%d" % i)
if with_after:
(d / "after-play.png").write_bytes(b"\x89PNG-after")
return str(d)
# ───────────────────────── ① parse_floor_judgment 纯函数 ─────────────────────────
def test_parse_accept():
r = cheap_verify.parse_floor_judgment(_accept_json())
assert r["degraded"] is False
assert r["accepted"] is True and r["verdict"] == "accept"
assert r["rejectClasses"] == []
assert len(r["checks"]) == 3 and {c["class"] for c in r["checks"]} == {"broken", "hollow", "off_brief"}
def test_parse_reject_hollow():
r = cheap_verify.parse_floor_judgment(_reject_json("hollow"))
assert r["degraded"] is False
assert r["accepted"] is False and r["verdict"] == "reject"
assert r["rejectClasses"] == ["hollow"]
def test_parse_reject_broken_and_offbrief():
node = {"broken": {"problem": True, "why": "b"}, "hollow": {"problem": False, "why": "x"},
"offBrief": {"problem": True, "why": "o"}, "verdict": "reject"}
r = cheap_verify.parse_floor_judgment(json.dumps(node))
assert r["accepted"] is False
assert set(r["rejectClasses"]) == {"broken", "off_brief"}
def test_parse_llm_verdict_reject_overrides():
"""三类 problem 都 false 但 LLM verdict=reject → 取交后仍判 reject(fail-closed 偏严,任一拒信号即拒)。"""
node = {"broken": {"problem": False}, "hollow": {"problem": False},
"offBrief": {"problem": False}, "verdict": "reject"}
r = cheap_verify.parse_floor_judgment(json.dumps(node))
assert r["accepted"] is False and r["rejectClasses"] == []
def test_parse_bare_bool_tolerance():
"""模型偷懒直接给裸 bool(非 {problem,why})也接住。"""
node = {"broken": False, "hollow": True, "offBrief": False, "verdict": "reject"}
r = cheap_verify.parse_floor_judgment(json.dumps(node))
assert r["accepted"] is False and r["rejectClasses"] == ["hollow"]
def test_parse_garbage_degraded():
r = cheap_verify.parse_floor_judgment("完全不是 JSON")
assert r["degraded"] is True and r["accepted"] is False and r["rejectClasses"] == ["degraded"]
def test_parse_missing_class_degraded():
"""缺任一类判定字段 → 无法确认地板 → fail-closed degraded(不静默判过)。"""
r = cheap_verify.parse_floor_judgment(json.dumps({"broken": {"problem": False}, "verdict": "accept"}))
assert r["degraded"] is True and r["accepted"] is False
def test_parse_none_empty_degraded():
assert cheap_verify.parse_floor_judgment(None)["degraded"] is True
assert cheap_verify.parse_floor_judgment("")["degraded"] is True
assert cheap_verify.parse_floor_judgment(" ")["degraded"] is True
def test_parse_fenced_json():
"""markdown 围栏 + 赘语 → json_repair 兜住。"""
text = "评定如下:\n```json\n" + _accept_json() + "\n```"
r = cheap_verify.parse_floor_judgment(text)
assert r["degraded"] is False and r["accepted"] is True
# ───────────────────────── ② judge_gameplay_floor 契约 ─────────────────────────
def test_judge_no_evidence_failclosed():
"""无截图证据(注入空 frames)→ fail-closed degraded,不放行,且未调用模型。"""
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", brief="b", frames=[], model=_FakeJudge(raise_exc=RuntimeError("不该被调用"))))
assert r["degraded"] is True and r["accepted"] is False
assert "证据缺失" in r.get("reason", "")
def test_judge_happy_accept():
"""注入 frames + fake accept → 结构化 accept、model/frames 观测。"""
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", brief="b", frames=["data:image/png;base64,AAAA"], model=_FakeJudge(text=_accept_json())))
assert r["degraded"] is False and r["accepted"] is True
assert r["model"] == cheap_verify._JUDGE_DEFAULT_MODEL and r["frames"] == 1
def test_judge_reject_from_model():
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=_FakeJudge(text=_reject_json("broken"))))
assert r["accepted"] is False and r["rejectClasses"] == ["broken"]
def test_judge_model_raises_degraded():
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=_FakeJudge(raise_exc=RuntimeError("boom"))))
assert r["degraded"] is True and r["accepted"] is False
def test_judge_timeout_degraded():
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"],
model=_FakeJudge(text=_accept_json(), sleep=0.3), timeout=0.05))
assert r["degraded"] is True and r["accepted"] is False
def test_judge_cost_accounting():
"""带 usage 的 fake + 打桩 _estimate_judge_cost → judgeTokens/costRmb 落进结果(零网络)。"""
orig = cheap_verify._estimate_judge_cost
cheap_verify._estimate_judge_cost = lambda mn, ti, to, cached: 0.087
try:
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=_FakeJudgeWithUsage(text=_accept_json())))
finally:
cheap_verify._estimate_judge_cost = orig
assert r["judgeTokens"] == {"in": 1000, "out": 200, "total": 1200}
assert r["costRmb"] == 0.087
# ───────────────────────── ③ apply_gameplay_judge ok 语义切换 ─────────────────────────
def test_apply_prefilter_false_no_judge():
"""预筛未过 → 不跑判定、不花¥;accepted=False、ok=False、judge.ran=False。"""
s = {"ok": False, "gameId": "g"}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(raise_exc=RuntimeError("不该被调用")), blocking=True))
assert out["accepted"] is False and out["ok"] is False
assert out["judge"]["ran"] is False
def test_apply_accept_blocking_ok_true():
"""预筛过 + 判定 accept + blocking → ok=accepted=True。"""
ev = _tmp_evidence()
s = {"ok": True, "gameId": "g"}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_accept_json()), blocking=True, evidence_dir=ev))
assert out["accepted"] is True and out["ok"] is True
assert out["judge"]["ran"] is True and out["judge"]["verdict"] == "accept"
def test_apply_reject_blocking_ok_false():
"""预筛过 + 判定 reject + blocking → ok=accepted=False(阻断放行)。"""
ev = _tmp_evidence()
s = {"ok": True, "gameId": "g"}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_reject_json("hollow")), blocking=True, evidence_dir=ev))
assert out["accepted"] is False and out["ok"] is False
assert out["judge"]["rejectClasses"] == ["hollow"]
def test_apply_degraded_blocking_failclosed():
"""预筛过 + 证据缺失(空目录)→ 判定 degraded → fail-closed → ok=False + failureReason 可重试。"""
ev = _tmp_evidence(with_first=False, with_after=False) # 空目录:无截图
s = {"ok": True, "gameId": "g"}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_accept_json()), blocking=True, evidence_dir=ev))
assert out["accepted"] is False and out["ok"] is False
assert out["judge"]["degraded"] is True
assert out.get("failureReason") == "llm_error" # degraded → 可重试类
def test_apply_reject_nonblocking_observe():
"""预筛过 + 判定 reject + blocking=false → ok=预筛(True,不阻断);accepted 仍记 False(观测/整体回退位)。"""
ev = _tmp_evidence()
s = {"ok": True, "gameId": "g"}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_reject_json("broken")), blocking=False, evidence_dir=ev))
assert out["ok"] is True # 观测模式:不阻断,ok 维持预筛
assert out["accepted"] is False # 判定裁决仍如实记(供观测/校准)
assert out["judge"]["blocking"] is False and out["judge"]["verdict"] == "reject"
# ───────────────────────── _collect_frames 排序/采样 + 金标读取器 ─────────────────────────
def test_collect_frames_order_and_cap():
"""顺序 first-paint → midplay-1..N → after-play;超 max_frames 保头尾均匀采样。"""
ev = _tmp_evidence(mids=8) # first + 8 mid + after = 10 张
uris = cheap_verify._collect_frames(ev, max_frames=4)
assert len(uris) == 4 # 头 + 2 采样 + 尾
# 头是 first-paint、尾是 after-play(解 base64 尾核对内容标记)
import base64 as _b64
head = _b64.b64decode(uris[0].split(",", 1)[1])
tail = _b64.b64decode(uris[-1].split(",", 1)[1])
assert head == b"\x89PNG-first" and tail == b"\x89PNG-after"
def test_collect_frames_natural_sort():
"""midplay-2 排在 midplay-10 之前(自然排序,非字典序)。"""
ev = _tmp_evidence(mids=12)
uris = cheap_verify._collect_frames(ev, max_frames=20) # 全收
assert len(uris) == 14 # first + 12 mid + after
import base64 as _b64
# 第 2 张(index1)应是 midplay-1
assert _b64.b64decode(uris[1].split(",", 1)[1]) == b"\x89PNG-mid1"
def test_load_golden_five_unlabeled_seeds():
"""金标 manifest:五份 score-only 样本登记为待标注种子(expectReject 全 null,不预设反例)。"""
g = judge_golden.load_golden()
assert g["schemaVersion"] == "judge-golden/1"
gids = [s["gid"] for s in g["samples"]]
assert gids == ["hard-heritage", "hard-trpg", "hard-idle-sim", "c2v-1", "c2v-3"]
assert all(s["expectReject"] is None for s in g["samples"]), "五份必须都未标注(双评审明令:不预设反例)"
assert judge_golden.labeled_samples(g) == [], "无已标注金标(待创始人亲玩定标)"
for s in g["samples"]:
assert s["brief"] and s["evidenceDir"] # 清单齐全
# ───────────────────── ⑤ 空输出有界重试 + 判定真相层落盘(W-AXIS 波2 验收补,2026-07-09)─────────────────────
# 背景:w2b-verify 真跑判定 degraded「判定输出为空」——glm-5.2 带思考,思考长度随机,吃光 max_tokens 时
# content 为空;主会话同证据复现即得答案 → 修=空输出重试一次(重试前放大 max_tokens)+ 判定落盘 evidence/。
class _FakeJudgeEmptyThenOk(_FakeJudge):
"""第一次返回空(模拟思考吃光 max_tokens),第二次返回合法 JSON——测有界重试恢复。"""
def __init__(self, ok_text):
super().__init__(text=ok_text)
self.calls = 0
self.max_tokens = 1500
async def __call__(self, messages, **kw):
self.calls += 1
if self.calls == 1:
return _FakeResp("")
return _FakeResp(self._text)
def test_judge_empty_retry_once_recovers():
m = _FakeJudgeEmptyThenOk(_accept_json())
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=m))
assert m.calls == 2, "空输出应重试一次(共两次调用)"
assert r["accepted"] is True and r["degraded"] is False
assert r.get("retries") == 1
assert m.max_tokens == 3000, "重试前应放大 max_tokens(思考吃光预算的对症解)"
def test_judge_empty_twice_degraded():
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=_FakeJudge(text="")))
assert r["degraded"] is True and r["accepted"] is False
assert "判定输出为空" in r["reason"]
def test_judge_persists_judge_json():
ev = _tmp_evidence()
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", evidence_dir=ev, model=_FakeJudge(text=_accept_json())))
assert r["accepted"] is True
jp = Path(ev) / "judge.json"
assert jp.is_file(), "判定必须在证据目录落 judge.json(判定器自身真相层)"
data = json.loads(jp.read_text(encoding="utf-8"))
assert data["accepted"] is True and data["verdict"] == "accept"
assert "rawTextHead" in data and data["ts"] > 0
def test_apply_explicit_prefilter_overrides_summary_ok():
"""显式 prefilter 参数必须压过 summary.ok(CLI 路修复,2026-07-09 W3 真跑暴露):
summary.ok=True(finished)但九门挂 → prefilter=False → 判定不跑、accepted=False。"""
s = {"ok": True}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(raise_exc=RuntimeError("预筛未过不该调判定")),
blocking=True, prefilter=False))
assert out["accepted"] is False and out["ok"] is False
assert out["judge"]["ran"] is False and "预筛" in out["judge"]["reason"]
def test_apply_explicit_prefilter_true_runs_judge():
"""prefilter=True 显式给真(即便 summary.ok=False 的殊形)→ 判定照跑,ok=判定结果。"""
s = {"ok": False}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_accept_json()),
blocking=True, prefilter=True, evidence_dir=_tmp_evidence()))
assert out["accepted"] is True and out["ok"] is True and out["judge"]["ran"] is True
def test_apply_writes_back_verdict_json():
ev = _tmp_evidence()
(Path(ev) / "verdict.json").write_text(
json.dumps({"pass": True, "guards": {}}), encoding="utf-8")
s = {"ok": True}
out = asyncio.run(cheap_verify.apply_gameplay_judge(
s, game_id="g", brief="b", model=_FakeJudge(text=_reject_json("hollow")),
blocking=True, evidence_dir=ev))
assert out["accepted"] is False and out["ok"] is False
v = json.loads((Path(ev) / "verdict.json").read_text(encoding="utf-8"))
assert v["pass"] is True, "pass 物理保留(语义=预筛通过)"
assert v["accepted"] is False, "accepted=最终权威(判定拒)"
assert v["judge"]["verdict"] == "reject" and v["judge"]["rejectClasses"] == ["hollow"]
# ───────────────────── ⑥ 图像盲微扰重掷(n=5 基线终审补,2026-07-09)─────────────────────
# 背景:网关对 glm-5.2 按请求体确定性路由,部分载荷永远落在丢图通道——判定 JSON 合法却自报看不见截图,
# 把好游戏错杀成 hollow/off_brief(基线 3/20 假阴)。同载荷复判 3/3 仍盲;brief 尾加一空格微扰即换路由当场看见。
# 修:输出契约加 imagesSeen 自报字段;送帧而自报 0 → 微扰重掷一次;两掷均盲 → 仪器故障 degraded。
def _accept_json_seen(n):
"""带 imagesSeen 自报的 accept JSON(n=模型自称看到的截图张数)。"""
d = json.loads(_accept_json())
d["imagesSeen"] = n
return json.dumps(d, ensure_ascii=False)
class _FakeJudgeBlindThenSeen(_FakeJudge):
"""第一掷自报 imagesSeen=0(模拟丢图通道),第二掷自报 6——测微扰重掷恢复;记每掷收到的 brief 头文本。"""
def __init__(self):
super().__init__()
self.calls = 0
self.brief_heads = []
async def __call__(self, messages, **kw):
self.calls += 1
# 记录 user 消息首个 text 块的头部(含 brief),供断言两掷载荷确实不同(微扰生效)
parts = messages[-1]["content"]
self.brief_heads.append(parts[0]["text"][:80])
return _FakeResp(_accept_json_seen(0 if self.calls == 1 else 6))
def test_parse_images_seen_passthrough():
assert cheap_verify.parse_floor_judgment(_accept_json_seen(6))["imagesSeen"] == 6
assert cheap_verify.parse_floor_judgment(_accept_json_seen(0))["imagesSeen"] == 0
assert cheap_verify.parse_floor_judgment(_accept_json())["imagesSeen"] is None, "缺字段=未知,不触发盲重掷"
bad = json.loads(_accept_json()); bad["imagesSeen"] = "abc"
assert cheap_verify.parse_floor_judgment(json.dumps(bad, ensure_ascii=False))["imagesSeen"] is None
bad["imagesSeen"] = True # bool 不算数字(True==1 的坑),按未知处理
assert cheap_verify.parse_floor_judgment(json.dumps(bad, ensure_ascii=False))["imagesSeen"] is None
def test_judge_image_blind_retry_recovers():
m = _FakeJudgeBlindThenSeen()
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", brief="掷骰爬塔", frames=["data:image/png;base64,AAAA"], model=m))
assert m.calls == 2, "自报 0 张应微扰重掷一次"
assert m.brief_heads[0] != m.brief_heads[1], "第二掷载荷必须与第一掷不同(微扰换网关路由)"
assert r["accepted"] is True and r["degraded"] is False
assert r.get("imageBlindRetried") is True and r.get("imagesSeen") == 6
assert r.get("retries") == 1
def test_judge_image_blind_twice_degraded():
m = _FakeJudge(text=_accept_json_seen(0)) # 两掷都自报看不见
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", brief="掷骰爬塔", frames=["data:image/png;base64,AAAA"], model=m))
assert r["degraded"] is True and r["accepted"] is False
assert r["rejectClasses"] == ["degraded"]
assert "看不见截图" in r["reason"], "归因必须是判定仪器故障,不是游戏证据缺失"
assert r.get("imageBlindRetried") is True
def test_judge_images_seen_positive_no_extra_call():
m = _FakeJudgeBlindThenSeen()
m.calls = 1 # 让首掷直接返回 seen=6(复用 fake:calls 从 2 起)
r = asyncio.run(cheap_verify.judge_gameplay_floor(
"x", frames=["data:image/png;base64,AAAA"], model=m))
assert m.calls == 2 and len(m.brief_heads) == 1, "自报看得见 → 不重掷"
assert r["accepted"] is True and r.get("imageBlindRetried") is None
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 AssertionError as e:
_failed += 1
print(f" FAIL {_fn.__name__}: {e}")
except Exception as e: # noqa: BLE001
_failed += 1
print(f" ERROR {_fn.__name__}: {type(e).__name__}: {e}")
print(f"\n{len(_fns) - _failed}/{len(_fns)} passed")
sys.exit(1 if _failed else 0)