187 lines
8.4 KiB
Python
187 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
r"""coverage_receipts.py — 六源回执明细化(B 覆盖总览面板·执行层)
|
||
|
||
各闸跑批把当场日志落 `.gates/*.log`;本工具在跑批末尾被闸壳调用一次,把六层日志解析成
|
||
「名×结果×计数」明细,按闸各写一个 JSONP .js 到 `.gates/coverage/`,供
|
||
tools/coverage_report.html 经 <script src> 直载。
|
||
|
||
六层与日志源(层键=面板消费键,层名=呈现名):
|
||
smoke 冒烟测试 smoke_boot.log 摘要「N 项检查,M 项失败」
|
||
unit 单元测试 unit.log 每套件「[run_all] PASS/FAIL res://…(exit=N)」
|
||
system 系统测试 system_test.log 摘要「N 项检查,M 项失败」
|
||
probe 场景探针 probe.log 每探针段「======== 名(段)========」+该段 PASS/FAIL 行
|
||
render 渲染闸 render.log golden 清单与对比结果
|
||
static 静态扫描 static_scan.log 每个 S 系条目「[static_scan] S# ok/FAIL(…)」
|
||
|
||
判据口径:
|
||
- 明细粒度=「名×结果×计数」,不记逐条断言文本(契约 §4 成本口径);
|
||
- 日志缺失=该层 {"result": "not_run"}——面板亮灰灯,不冒充绿;
|
||
- 本工具失败不翻转闸判定(观测件非判定件),但必须打红字——静默丢数据等于断言蒸发。
|
||
|
||
用法: python tools/coverage_receipts.py [--stamp-dir <.gates 路径>]
|
||
产出: <stamp-dir>/coverage/gate_<层键>.js × 6
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import datetime
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
DEFAULT_STAMP = ROOT / "game" / "tests" / ".gates"
|
||
|
||
SUMMARY_RE = re.compile(r"摘要[::]\s*\[?[^\]]*\]?\s*摘要[::]\s*(\d+)\s*项检查,(\d+)\s*项失败")
|
||
SUITE_RE = re.compile(r"\[run_all\] (PASS|FAIL) (res://\S+?\.gd)(?:\s*\(exit=(-?\d+)\))?")
|
||
PROBE_SECTION_RE = re.compile(r"^======== (\S+)(?:\s+(.+?))? ========")
|
||
PROBE_RESULT_RE = re.compile(r"^\[[^\]]+\] (PASS|FAIL)")
|
||
GOLDEN_RE = re.compile(r"\[render_gate\] \[golden\] (PASS|FAIL)((\d+) 屏黄金图")
|
||
GOLDEN_CROP_RE = re.compile(r"\[render_gate\] \[golden_crop\] (\S+?) —")
|
||
STATIC_RE = re.compile(r"^\[static_scan\] (S\d+(?:/S\d+)*)\s+(ok|FAIL)", re.M)
|
||
STATIC_GDSTYLE_RE = re.compile(r"^\[gdstyle_gate\] (ok|FAIL)", re.M)
|
||
|
||
|
||
def read(stamp: Path, name: str) -> str:
|
||
p = stamp / name
|
||
if not p.is_file():
|
||
return ""
|
||
return p.read_text(encoding="utf-8", errors="replace")
|
||
|
||
|
||
def parse_summary_layer(log: str) -> dict:
|
||
if not log:
|
||
return {"result": "not_run"}
|
||
m = SUMMARY_RE.search(log)
|
||
verdict = "绿" if "[run_smoke] PASS" in log or "[run_system_test] PASS" in log else (
|
||
"红" if ("FAIL" in log or "摘要" in log and m and int(m.group(2)) > 0) else "灰")
|
||
if m:
|
||
return {"result": verdict, "checks": int(m.group(1)), "fails": int(m.group(2))}
|
||
return {"result": verdict}
|
||
|
||
|
||
def parse_unit(log: str) -> dict:
|
||
if not log:
|
||
return {"result": "not_run"}
|
||
items = [{"name": m.group(2), "result": m.group(1), "exit": int(m.group(3) or 0)}
|
||
for m in SUITE_RE.finditer(log)]
|
||
red = [i for i in items if i["result"] == "FAIL"]
|
||
verdict = "绿" if "[run_all] all green" in log and not red else ("红" if red or "FAIL" in log else "灰")
|
||
return {"result": verdict, "suites_total": len(items), "items": items}
|
||
|
||
|
||
def parse_probe(log: str) -> dict:
|
||
if not log:
|
||
return {"result": "not_run"}
|
||
items: list[dict] = []
|
||
cur: dict | None = None
|
||
for line in log.splitlines():
|
||
sm = PROBE_SECTION_RE.match(line.strip())
|
||
if sm:
|
||
seg = (sm.group(2) or "").strip()
|
||
cur = {"name": sm.group(1), "segment": seg, "result": "灰"}
|
||
items.append(cur)
|
||
continue
|
||
rm = PROBE_RESULT_RE.match(line.strip())
|
||
if rm and cur is not None and cur["result"] == "灰":
|
||
cur["result"] = rm.group(1)
|
||
judged = [i for i in items if i["result"] != "灰"]
|
||
verdict = "红" if (any(i["result"] == "FAIL" for i in judged) or "[probe_gate] 全绿" not in log) else "绿"
|
||
return {"result": verdict, "probes_total": len(judged), "items": items}
|
||
|
||
|
||
def parse_render(log: str) -> dict:
|
||
if not log:
|
||
return {"result": "not_run"}
|
||
items: list[dict] = []
|
||
gm = GOLDEN_RE.search(log)
|
||
if gm:
|
||
items.append({"name": "golden 逐像素对比", "result": gm.group(1), "screens": int(gm.group(2))})
|
||
screens: list[str] = []
|
||
for m in GOLDEN_CROP_RE.finditer(log):
|
||
if m.group(1) not in screens:
|
||
screens.append(m.group(1))
|
||
if screens:
|
||
items.append({"name": "golden 逐屏裁剪", "result": "PASS", "screens": screens})
|
||
verdict = "红" if "FAIL" in log or "[render_gate] 全绿" not in log else "绿"
|
||
return {"result": verdict, "items": items}
|
||
|
||
|
||
def parse_static(log: str) -> dict:
|
||
if not log:
|
||
return {"result": "not_run"}
|
||
items = [{"name": m.group(1), "result": m.group(2)} for m in STATIC_RE.finditer(log)]
|
||
gm = STATIC_GDSTYLE_RE.search(log)
|
||
if gm:
|
||
items.insert(0, {"name": "gdstyle", "result": gm.group(1)})
|
||
red = [i for i in items if i["result"] != "ok"]
|
||
verdict = "红" if red or "FAIL" in log else "绿"
|
||
return {"result": verdict, "items_total": len(items), "items": items}
|
||
|
||
|
||
LAYERS = [
|
||
("smoke", "冒烟测试", "smoke_boot.log", parse_summary_layer),
|
||
("unit", "单元测试", "unit.log", parse_unit),
|
||
("system", "系统测试", "system_test.log", parse_summary_layer),
|
||
("probe", "场景探针", "probe.log", parse_probe),
|
||
("render", "渲染闸", "render.log", parse_render),
|
||
("static", "静态扫描", "static_scan.log", parse_static),
|
||
]
|
||
|
||
|
||
def main() -> int:
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
ap = argparse.ArgumentParser(description="六源回执明细化(B 面板·执行层)")
|
||
ap.add_argument("--stamp-dir", default=str(DEFAULT_STAMP))
|
||
args = ap.parse_args()
|
||
stamp = Path(args.stamp_dir)
|
||
out_dir = stamp / "coverage"
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
failed = False
|
||
wrote = 0
|
||
for key, label, log_name, fn in LAYERS:
|
||
try:
|
||
detail = fn(read(stamp, log_name))
|
||
detail["meta"] = {
|
||
"layer": key,
|
||
"label": label,
|
||
"source_log": log_name,
|
||
"generated_at": datetime.datetime.now().isoformat(timespec="seconds"),
|
||
}
|
||
payload = {"receipt": detail}
|
||
js = "/* 闸回执明细化——coverage_receipts.py 随每次跑批覆写,人工修订无效。 */\n"
|
||
js += "window.__QA_COVERAGE__ = window.__QA_COVERAGE__ || {};\n"
|
||
js += "window.__QA_COVERAGE__.gate_receipts = window.__QA_COVERAGE__.gate_receipts || {};\n"
|
||
js += "window.__QA_COVERAGE__.gate_receipts[%s] = %s;\n" % (
|
||
json.dumps(key), json.dumps(payload, ensure_ascii=False, indent=2))
|
||
with open(str(out_dir / ("gate_%s.js" % key)), "w", encoding="utf-8", newline="\n") as fh:
|
||
fh.write(js)
|
||
wrote += 1
|
||
print("[receipts] %s(%s)=%s" % (label, log_name, detail.get("result", "?")))
|
||
except Exception as exc: # noqa: BLE001 —— 观测件逐层容错,红字可见不静默
|
||
failed = True
|
||
print("[receipts] 红:%s 层解析失败(%s):%r" % (label, log_name, exc))
|
||
if wrote == 0:
|
||
print("[receipts] 红:六层明细一件未落盘——禁静默")
|
||
return 1
|
||
# 探针登记文件清单(B 面板 file:// 无法列目录——按现存 probe_hits_*.js 落 manifest 供 <script src>)
|
||
probe_files = sorted(p.name for p in out_dir.glob("probe_hits_*.js"))
|
||
manifest = {
|
||
"meta": {"generated_at": datetime.datetime.now().isoformat(timespec="seconds")},
|
||
"probes": probe_files,
|
||
}
|
||
mjs = "/* 探针登记清单——coverage_receipts.py 随跑批按现存文件覆写,勿手编。 */\n"
|
||
mjs += "window.__QA_COVERAGE__ = window.__QA_COVERAGE__ || {};\n"
|
||
mjs += "window.__QA_COVERAGE__.probe_manifest = %s;\n" % json.dumps(manifest, ensure_ascii=False)
|
||
with open(str(out_dir / "probe_manifest.js"), "w", encoding="utf-8", newline="\n") as fh:
|
||
fh.write(mjs)
|
||
print("[receipts] 探针登记清单 %d 件 → probe_manifest.js" % len(probe_files))
|
||
return 1 if failed else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|