批2(QA harness·B覆盖总览面板+本体枚举器): 义务层=tools/extract_capability_list.py(README能力清单四列表按旅程段解析,7段103行,用例指针剥反引号); 执行层=tools/coverage_receipts.py六源回执明细化(冒烟/单元集成/系统/探针/渲染/静态各一.js,名×结果×计数粒度,日志缺失=not_run亮灰不冒充绿)+run_all_gates.sh末尾挂点(观测件非判定件,解析失败打红字不翻闸判)+probe_manifest.js(面板file://无法列目录,按现存登记文件落清单); 机器义务双源之本体源=tools/coverage_enum.py(两扫描器+比对:.tres行集/schema词表字段/tscn按钮/gui_input自绘类/屏模态清单×extract_checks断言文料,865实体/缺口212); 呈现层=tools/coverage_report.html(四节:六层闸况/能力行×六层灯按旅程段/控件下钻交互源+渲染golden展示源/本体对账缺口×探针命中降级探针承载;状态灯四态+数据时间戳陈旧可见+完备性边界页眉;JSONP直载file://即开); 豁免=tools/coverage_exemptions.js人工维护母版; 登记件扩seen面(场景树全量BaseButton+db布局表hotspot_key公共面枚举,seen-hit=存在但零真输入覆盖缺口数据源)。读数: 两探针窄跑绿(screen_flow 19命中/116存在,ui_click 27命中/91存在——停手钮/树购买重建代等真缺口在册,弃图刷新确认链走视图方法属衔接条目余项); 六源明细化对真实闸日志抽核(unit82套件/系统69检查/探针10段/静态13条/golden逐屏L1-L3); 面板浏览器真实入口四节渲染验过; 全量档闸【验收通过】无新增红(exit=0,回执commit 07d4977f,六源明细化在闸内实跑=挂点真路径验证)

This commit is contained in:
DKChan committed 2026-11-06 03:21:10 +08:00
1 parent 07d4977f41
commit bb9ce8a117
7 files changed
+745

No files matched your search

+35
View File
@@ -164,6 +164,38 @@ func _bump(key: String) -> void:
_hits[key] = int(_hits.get(key, 0)) + 1
# ---- 场景树全量交互面枚举(控件缺口数据源:seen−hit=「存在但零真输入覆盖」)----
# 成员面与登记同源:场景树全部 BaseButton(main 建树常驻——未开过的屏的钮也算存在;
# 已接线钮复用接线键防序位错位,未接线者落盘期才补键——落盘是终态动作,序位计数此后不再消费)
# +营地物件(经 db 布局表 hotspot_key 公共数据面枚举;物件命中见 _on_camp_object 的 #物件id 后缀)。
func enumerate_seen() -> Array[String]:
var keys: Array[String] = []
_seen_into(_main, keys)
var db = _main.get("db")
if db != null and db.get("presentation") != null:
var layout = db.presentation.camp_scene_layout
if layout != null:
for spec in layout.layers:
var hk: StringName = spec.hotspot_key
var ck := "camp_object#" + String(hk)
if hk != &"" and not keys.has(ck):
keys.append(ck)
return keys
func _seen_into(node: Node, keys: Array[String]) -> void:
if node is BaseButton:
var id := node.get_instance_id()
var key := String(_node_keys.get(id, ""))
if key == "":
key = _assign_key(node)
_node_keys[id] = key
if not keys.has(key):
keys.append(key)
for child in node.get_children():
_seen_into(child, keys)
# ---- 落盘(JSONP 式 .js——file:// 页面 <script src> 直载)----
func dump_js() -> void:
@@ -183,6 +215,7 @@ func dump_js() -> void:
var total := 0
for k in _hits:
total += int(_hits[k])
var seen := enumerate_seen()
var payload := {
"meta": {
"probe": _probe_name,
@@ -192,9 +225,11 @@ func dump_js() -> void:
"driver": "InputEvent via Viewport.push_input(真输入管线)",
"key_shape": "锚链归一化(具名/脚本锚+类#文本+序位;@ 临时名不入键)",
"hits_total": total,
"seen_total": seen.size(),
"skipped_temp_names": _skipped_temp,
},
"hits": _hits,
"seen": seen,
}
var js := "/* 真输入覆盖登记——探针每次运行自动覆写,人工修订无效(豁免面走人工清单,不落本文件)。 */\n"
js += "window.__QA_COVERAGE__ = window.__QA_COVERAGE__ || {};\n"
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""coverage_enum.py — 本体枚举器(游戏内容↔测试验证对账,B 面板·义务层双源之机器源)
两个文本扫描器+一次比对,全程不依赖跑游戏(交互命中维度除外——由探针覆盖登记另行承载):
扫描器 1(游戏本体枚举)——「游戏有什么」:
game/data/tables/*.tres 行集主键(`"xxx_id": "yyy"` 形态,逐表逐行)
game/data/schemas/*.gd 词表常量与字段名(const 块内的引号 token+var 字段名)
game/presentation/**/*.tscn BaseButton 族节点(scene+节点名)
game/presentation/**/*.gd _gui_input/_input 自绘命中类(脚本名)
game/presentation/views/*.gd 屏与模态清单(*_screen/*_overlay/*_panel 视图类)
扫描器 2(验证关联)——「验证了什么」:复用 tools/extract_checks.py 的断言静态提取
(unit/**+integration/**+system_test.gd 全量),实体 id 在「断言标签+cond+源文件+函数」
文料中出现即记一次关联。
比对=差集:本体存在、验证零关联=缺口清单,按类别分组、标首见批次(本工具生成时间)。
诚实边界(契约 §7 在案):grep 关联有假阳假阴——断言测了实体但文本没带 id=假缺口(人工核销
兜);文本带 id 但断言弱=假覆盖(oracle 改造堵)。推荐约定「断言文本含被测实体 id」提高精度,
不立规约。交互点(按钮/物件被点过没有)不在本工具判据内——那归探针真输入覆盖登记。
用法: python tools/coverage_enum.py [--out <路径>]
产出: game/tests/.gates/coverage/ontology_gaps.js(JSONP,B 面板消费)
"""
from __future__ import annotations
import argparse
import datetime
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
GAME = ROOT / "game"
DEFAULT_OUT = GAME / "tests" / ".gates" / "coverage" / "ontology_gaps.js"
TRES_ID_RE = re.compile(r'"(\w+_id)":\s*"([^"]+)"')
SCHEMA_TOKEN_RE = re.compile(r'"([a-z][a-z0-9_]{2,40})"')
SCHEMA_VAR_RE = re.compile(r"^@?export\s+var\s+(\w+)\s*:", re.M)
TSCN_NODE_RE = re.compile(r'\[node name="([^"]+)"[^>]*type="(\w*Button\w*)"')
GUI_INPUT_RE = re.compile(r"^func (_gui_input|_input)\(", re.M)
VIEW_KIND_RE = re.compile(r"(screen|overlay|panel)$")
def scan_tables() -> list[dict]:
out: list[dict] = []
tables = GAME / "data" / "tables"
for f in sorted(tables.glob("*.tres")):
text = f.read_text(encoding="utf-8", errors="replace")
seen: set[tuple[str, str]] = set()
for m in TRES_ID_RE.finditer(text):
key = (m.group(1), m.group(2))
if key not in seen:
seen.add(key)
out.append({"category": "表行:%s" % f.stem, "id": m.group(2), "id_field": m.group(1)})
return out
def scan_schemas() -> list[dict]:
out: list[dict] = []
schemas = GAME / "data" / "schemas"
for f in sorted(schemas.glob("*.gd")):
text = f.read_text(encoding="utf-8", errors="replace")
tokens = set(SCHEMA_TOKEN_RE.findall(text))
fields = set(SCHEMA_VAR_RE.findall(text))
for t in sorted(tokens):
out.append({"category": "词表/枚值:%s" % f.stem, "id": t, "id_field": "token"})
for fl in sorted(fields):
out.append({"category": "schema字段:%s" % f.stem, "id": fl, "id_field": "field"})
return out
def scan_tscn_buttons() -> list[dict]:
out: list[dict] = []
for f in sorted(GAME.glob("presentation/**/*.tscn")):
text = f.read_text(encoding="utf-8", errors="replace")
for m in TSCN_NODE_RE.finditer(text):
out.append({"category": "场景按钮", "id": "%s:%s" % (f.name, m.group(1)), "id_field": m.group(2)})
return out
def scan_gui_input_classes() -> list[dict]:
out: list[dict] = []
for f in sorted(GAME.glob("presentation/**/*.gd")):
text = f.read_text(encoding="utf-8", errors="replace")
kinds = set(GUI_INPUT_RE.findall(text))
for k in kinds:
out.append({"category": "自绘命中类", "id": f.stem, "id_field": k})
break # 一类一行(一个脚本同具两面也只记一次)
return out
def scan_screens() -> list[dict]:
out: list[dict] = []
for f in sorted((GAME / "presentation" / "views").glob("*.gd")):
if VIEW_KIND_RE.search(f.stem):
out.append({"category": "屏与模态", "id": f.stem, "id_field": "view"})
return out
def assertion_corpus() -> tuple[list[dict], str]:
sys.path.insert(0, str(ROOT / "tools"))
import extract_checks as ec # noqa: PLC0415 —— 复用现役提取机制(契约 §7 指定扩面)
rows, _problems = ec.extract_all(ec.default_sources())
parts: list[str] = []
for r in rows:
parts.append("%s %s %s %s" % (r.get("src", ""), r.get("func", ""),
r.get("label_literal", ""), r.get("cond", "")))
return rows, "\n".join(parts)
def main() -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
ap = argparse.ArgumentParser(description="本体枚举器(游戏内容↔测试验证对账)")
ap.add_argument("--out", default=str(DEFAULT_OUT))
args = ap.parse_args()
entities = scan_tables() + scan_schemas() + scan_tscn_buttons() + scan_gui_input_classes() + scan_screens()
_rows, corpus = assertion_corpus()
categories: dict[str, dict] = {}
for ent in entities:
cat = categories.setdefault(ent["category"], {"total": 0, "matched": 0, "gaps": []})
cat["total"] += 1
if ent["id"] and ent["id"] in corpus:
cat["matched"] += 1
else:
cat["gaps"].append({"id": ent["id"], "id_field": ent.get("id_field", "")})
payload = {
"meta": {
"generated_at": datetime.datetime.now().isoformat(timespec="seconds"),
"entities_total": len(entities),
"corpus_rows": len(_rows),
"honest_boundary": "grep 关联有假阳假阴——文本没带 id 的假缺口人工核销;交互命中归探针真输入登记",
},
"categories": [
{"category": k, "total": v["total"], "matched": v["matched"], "gaps": v["gaps"]}
for k, v in sorted(categories.items())
],
}
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
js = "/* 本体对账清单——coverage_enum.py 随跑重生成,人工核销走豁免清单(coverage_exemptions.js),不落本文件。 */\n"
js += "window.__QA_COVERAGE__ = window.__QA_COVERAGE__ || {};\n"
js += "window.__QA_COVERAGE__.ontology = %s;\n" % json.dumps(payload, ensure_ascii=False, indent=2)
out.write_text(js, encoding="utf-8", newline="\n")
gap_total = sum(len(c["gaps"]) for c in payload["categories"])
print("[ontology] 本体实体 %d / 断言文料行 %d / 缺口 %d(%d 类)→ %s" % (
len(entities), len(_rows), gap_total, len(payload["categories"]), out))
for c in payload["categories"]:
print(" %s:%d 实体 / %d 缺口" % (c["category"], c["total"], len(c["gaps"])))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+11
View File
@@ -0,0 +1,11 @@
/* 覆盖豁免清单——人工维护(S13 基线人工修订同款纪律;面板与工具只读本文件,禁代码生成)。
* 用途:登记「确认不需要真输入覆盖/不需要断言关联」的条目,B 面板对在册条目亮豁免灯、不亮缺口。
* 形态:abilities=能力清单行「能力」列原文(精确匹配);controls=控件登记键或其中缀(包含匹配);
* ontology=本体实体 id(精确匹配)。每条必须带 reason——无理由的豁免条目面板标红示警。 */
window.__QA_COVERAGE__ = window.__QA_COVERAGE__ || {};
window.__QA_COVERAGE__.exemptions = {
"meta": {"note": "人工维护;改后刷新面板即生效,无生成步骤"},
"abilities": [],
"controls": [],
"ontology": []
};
+184
View File
@@ -0,0 +1,184 @@
#!/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))
(out_dir / ("gate_%s.js" % key)).write_text(js, encoding="utf-8", newline="\n")
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)
(out_dir / "probe_manifest.js").write_text(mjs, encoding="utf-8", newline="\n")
print("[receipts] 探针登记清单 %d 件 → probe_manifest.js" % len(probe_files))
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
+226
View File
@@ -0,0 +1,226 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>QA 覆盖总览面板——Dungeon Sweeper</title>
<style>
:root { --ok:#3a7d44; --bad:#a33; --off:#9aa; --na:#d5d5d0; --ex:#b8860b; --ink:#2c2a26; --paper:#f4f1ea; }
body { font-family: "Microsoft YaHei", sans-serif; margin: 0; background: var(--paper); color: var(--ink); }
header { padding: 14px 20px 10px; border-bottom: 3px solid var(--ink); background: #fff; }
h1 { font-size: 19px; margin: 0 0 4px; }
h2 { font-size: 16px; margin: 22px 0 6px; border-left: 5px solid var(--ink); padding-left: 8px; }
.note { font-size: 12px; color: #555; margin: 3px 0; }
.warn { color: var(--bad); font-weight: bold; }
main { padding: 12px 20px 40px; }
table { border-collapse: collapse; width: 100%; background: #fff; font-size: 12.5px; }
th, td { border: 1px solid #cfc9bd; padding: 4px 7px; text-align: left; vertical-align: top; }
th { background: #ebe6da; }
.lamp { display: inline-block; width: 13px; height: 13px; border-radius: 50%; margin-right: 3px; vertical-align: -2px; }
.ok { background: var(--ok); } .bad { background: var(--bad); }
.off { background: var(--off); } .na { background: var(--na); } .ex { background: #fff; border: 3px double var(--ex); }
.chip { display: inline-block; padding: 1px 8px; border-radius: 9px; color: #fff; font-size: 12px; }
details { margin: 4px 0 10px; } summary { cursor: pointer; font-size: 13px; }
.gaplist { font-size: 12px; color: #444; line-height: 1.7; }
.seg { margin-bottom: 18px; }
code { background: #eceade; padding: 0 3px; }
</style>
</head>
<body>
<header>
<h1>QA 覆盖总览面板 <span style="font-weight:normal;font-size:13px">(义务 × 执行合并视图——只读)</span></h1>
<div class="note">状态灯:绿=该层最近跑批过 红=跑批挂 灰=该层无承载/未跑 双圈=豁免(<code>tools/coverage_exemptions.js</code> 人工维护)。</div>
<div class="note" id="staleness"></div>
<div class="note"><b>完备性边界</b>:控件级视图只对「被探针/C 打开过的屏」负责;未打开过的屏由动线断言+C 探索兜底。
本体对账为文本 grep 关联——有假阳假阴(断言测了实体但文本没带 id=假缺口,人工核销;文本带 id 但断言弱=假覆盖),缺口须人工复核后再立事项。</div>
<div class="note">数据源:随最近一次闸批落盘于 <code>game/tests/.gates/coverage/</code>;刷新本页即读最新。</div>
</header>
<main>
<h2>一、六层闸况</h2>
<div id="gateSummary"></div>
<h2>二、能力清单 × 六层状态灯(按旅程段)</h2>
<div class="note">灯的判据:用例文件落哪层就钉哪层的回执(单元=逐套件 PASS/FAIL;探针/系统/冒烟/渲染/静态=层判据);该层无对应承载=灰。</div>
<div id="capabilityMatrix"></div>
<h2>三、控件级交互覆盖下钻(探针真输入登记)</h2>
<div class="note">交互源=探针真输入命中登记(push_input 管线命中);「存在但零命中」=控件缺口——hitbox 错位/遮挡/驱动空洞在此暴露。展示源=渲染闸 golden 逐屏登记。</div>
<div id="controlDrill"></div>
<h2>四、本体对账(游戏内容 ↔ 测试验证)</h2>
<div class="note">缺口=本体存在、断言文料零关联;若该 id 出现在探针命中登记则降级为「探针承载」。首见批次=本工具生成时间,逐批对比看新面孔。</div>
<div id="ontologyGaps"></div>
</main>
<script src="./coverage_exemptions.js"></script>
<script src="../game/tests/.gates/coverage/capability_list.js"></script>
<script src="../game/tests/.gates/coverage/gate_smoke.js"></script>
<script src="../game/tests/.gates/coverage/gate_unit.js"></script>
<script src="../game/tests/.gates/coverage/gate_system.js"></script>
<script src="../game/tests/.gates/coverage/gate_probe.js"></script>
<script src="../game/tests/.gates/coverage/gate_render.js"></script>
<script src="../game/tests/.gates/coverage/gate_static.js"></script>
<script src="../game/tests/.gates/coverage/probe_manifest.js"></script>
<script src="../game/tests/.gates/coverage/ontology_gaps.js"></script>
<script>
/* 探针登记文件由 manifest 同步补挂(document.write 在解析期阻塞加载,渲染段前数据已就位) */
(function () {
const probes = ((window.__QA_COVERAGE__ || {}).probe_manifest || {}).probes || [];
for (const p of probes) {
document.write('<script src="../game/tests/.gates/coverage/' + encodeURIComponent(p) + '"><\/script>');
}
})();
</script>
<script>
"use strict";
const C = window.__QA_COVERAGE__ || {};
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
function lamp(cls, title) { return `<span class="lamp ${cls}" title="${esc(title)}"></span>`; }
function chip(txt, cls) { return `<span class="chip" style="background:${cls}">${esc(txt)}</span>`; }
const VCOLOR = {"绿":"var(--ok)","红":"var(--bad)","灰":"var(--off)","not_run":"var(--off)"};
// ── 数据新鲜度(陈旧可见——防静默旧数据) ──
(function () {
const stamps = [];
const cl = (C.capability_list || {}).meta; if (cl) stamps.push(`义务层 ${cl.generated_at}`);
const rc = C.gate_receipts || {};
for (const k in rc) { const m = (rc[k].receipt || {}).meta; if (m) stamps.push(`${m.label} ${m.generated_at}`); }
const on = (C.ontology || {}).meta; if (on) stamps.push(`本体 ${on.generated_at}`);
const pm = (C.probe_manifest || {}).meta; if (pm) stamps.push(`探针登记 ${pm.generated_at}`);
document.getElementById("staleness").innerHTML = "数据时间:" + stamps.map(esc).join(" · ");
})();
// ── 一、六层闸况 ──
(function () {
const rc = C.gate_receipts || {};
const ORDER = ["smoke", "unit", "system", "probe", "render", "static"];
const LABEL = {smoke:"冒烟测试", unit:"单元与集成测试", system:"系统测试", probe:"场景探针", render:"渲染闸", static:"静态扫描"};
let html = "<table><tr><th>层</th><th>判定</th><th>明细</th></tr>";
for (const k of ORDER) {
const r = (rc[k] || {}).receipt;
if (!r) { html += `<tr><td>${LABEL[k]}</td><td>${chip("缺数据", "var(--off)")}</td><td>—</td></tr>`; continue; }
const v = r.result === "not_run" ? "未跑" : r.result;
const color = VCOLOR[r.result] || "var(--off)";
let detail = `共 ${r.items ? r.items.length : 0} 项`;
if (r.checks != null) detail = `${r.checks} 项检查 / ${r.fails} 失败`;
if (r.suites_total != null) detail = `${r.suites_total} 套件`;
if (r.probes_total != null) detail = `${r.probes_total} 探针`;
html += `<tr><td>${LABEL[k]}</td><td>${chip(v, color)}</td><td>${esc(detail)} <span class="note">源=${esc(r.meta.source_log)} @ ${esc(r.meta.generated_at)}</span></td></tr>`;
}
html += "</table><details><summary>单元与集成逐套件明细</summary><div class='gaplist'>";
const unit = ((rc.unit || {}).receipt || {}).items || [];
for (const it of unit) {
html += `${lamp(it.result === "PASS" ? "ok" : "bad", it.result)} ${esc(it.name.replace("res://tests/", ""))}${it.exit ? "(exit=" + esc(it.exit) + ")" : ""}<br>`;
}
html += "</div></details><details><summary>场景探针逐段明细</summary><div class='gaplist'>";
const pr = ((rc.probe || {}).receipt || {}).items || [];
for (const it of pr) {
html += `${lamp(it.result === "PASS" ? "ok" : (it.result === "FAIL" ? "bad" : "off"), it.result)} ${esc(it.name)}${it.segment ? "(" + esc(it.segment) + ")" : ""}<br>`;
}
html += "</div></details><details><summary>静态扫描逐条明细</summary><div class='gaplist'>";
const st = ((rc.static || {}).receipt || {}).items || [];
for (const it of st) {
html += `${lamp(it.result === "ok" ? "ok" : "bad", it.result)} ${esc(it.name)}<br>`;
}
html += "</div></details>";
document.getElementById("gateSummary").innerHTML = html;
})();
// ── 二、能力清单 × 六层灯 ──
(function () {
const cl = C.capability_list;
if (!cl || !cl.segments) { document.getElementById("capabilityMatrix").innerHTML = "<p class='warn'>义务层缺数据——先跑 tools/extract_capability_list.py</p>"; return; }
const rc = C.gate_receipts || {};
const ex = ((C.exemptions || {}).abilities || []).map(s => String(s));
function layerOf(caseFile) {
if (/system_test/.test(caseFile)) return "system";
if (/probe\//.test(caseFile) || /_probe\.gd/.test(caseFile)) return "probe";
if (/golden|render/.test(caseFile)) return "render";
return "unit";
}
function rowLight(row) {
if (ex.includes(row.ability)) return lamp("ex", "豁免(coverage_exemptions.js 在册)");
if (!row.cases || !row.cases.length) return lamp("off", "无代表用例指针");
let worst = null; const seenLayers = new Set();
for (const c of row.cases) {
const layer = layerOf(c);
seenLayers.add(layer);
const r = (rc[layer] || {}).receipt;
if (!r || r.result === "not_run") { worst = worst || "na"; continue; }
if (r.result === "红") return lamp("bad", `${layer} 层跑批挂`);
if (layer === "unit") {
const items = r.items || [];
const hit = items.filter(it => c.indexOf(it.name.replace("res://tests/", "")) >= 0 || it.name.indexOf(c.split(":")[0].replace(".gd", "")) >= 0);
if (!hit.length) { worst = worst || "off"; continue; }
if (hit.some(it => it.result !== "PASS")) return lamp("bad", "代表套件红:" + c);
worst = worst || "ok";
} else {
worst = worst || (r.result === "绿" ? "ok" : "off");
}
}
if (worst === "ok") return lamp("ok", "承载层绿");
if (worst === "na") return lamp("na", "承载层本批未跑");
return lamp("off", "该层无对应承载(灰)");
}
let html = "";
for (const seg of cl.segments) {
if (!seg.rows.length) continue;
html += `<div class="seg"><h3 style="font-size:14px;margin:8px 0 4px">${esc(seg.segment)}(${seg.rows.length} 行)</h3>`;
html += "<table><tr><th style='width:32%'>能力</th><th>系统</th><th style='width:14%'>灯</th><th style='width:26%'>代表用例</th></tr>";
for (const row of seg.rows) {
html += `<tr><td>${esc(row.ability)}</td><td>${esc(row.system)}</td><td>${rowLight(row)}</td><td>${row.cases.map(esc).join("<br>")}</td></tr>`;
}
html += "</table></div>";
}
document.getElementById("capabilityMatrix").innerHTML = html;
})();
// ── 三、控件级下钻 ──
(function () {
const pm = C.probe_manifest || {probes: []};
const ex = ((C.exemptions || {}).controls || []).map(s => String(s));
const rd = ((C.gate_receipts || {}).render || {}).receipt || {};
const renderScreens = [];
for (const it of (rd.items || [])) if (it.screens instanceof Array) renderScreens.push(...it.screens);
let html = "";
if (renderScreens.length) {
html += `<p class="note"><b>渲染展示登记(golden 逐屏)</b>:${renderScreens.map(esc).join("、")}</p>`;
}
for (const pf of pm.probes) {
const name = pf.replace(/^probe_hits_/, "").replace(/\.js$/, "");
// 同名重复 <script> 无法动态注入——manifest 落盘时同步内联一份数据入口由面板经 window 取
const data = (window.__QA_COVERAGE__.probe_real_input || {})[name];
if (!data) { html += `<p class="warn">${esc(name)}:登记文件未加载(检查 script 标签与 manifest)</p>`; continue; }
const hits = data.hits || {};
const seen = data.seen || [];
const gaps = seen.filter(k => !(k in hits) && !ex.some(e => k.indexOf(e) >= 0));
html += `<details open><summary><b>${esc(name)}</b>:命中 ${Object.keys(hits).length} 控件 / 存在 ${seen.length} 控件 / 零命中缺口 ${gaps.length}(@ ${esc(data.meta.generated_at)})</summary><div class='gaplist'>`;
for (const g of gaps) html += `${lamp("off", "存在但零真输入命中")} ${esc(g)}<br>`;
html += "<b>已命中:</b><br>";
for (const k of Object.keys(hits).sort()) html += `${lamp("ok", "真输入命中 " + hits[k] + " 次")} ${esc(k)}×${hits[k]}<br>`;
html += "</div></details>";
}
document.getElementById("controlDrill").innerHTML = html || "<p class='warn'>探针登记缺数据——先跑探针与 tools/coverage_receipts.py</p>";
})();
// ── 四、本体对账 ──
(function () {
const on = C.ontology;
if (!on || !on.categories) { document.getElementById("ontologyGaps").innerHTML = "<p class='warn'>本体数据缺——先跑 tools/coverage_enum.py</p>"; return; }
const ex = ((C.exemptions || {}).ontology || []).map(s => String(s));
const hitTexts = [];
const pri = (C.probe_real_input || {});
for (const k in pri) for (const hk of Object.keys(pri[k].hits || {})) hitTexts.push(hk);
let html = `<p class="note">实体 ${esc(on.meta.entities_total)} · 断言文料 ${esc(on.meta.corpus_rows)} 行 · 首见批次 ${esc(on.meta.generated_at)}</p>`;
for (const cat of on.categories) {
const shown = cat.gaps.filter(g => !ex.includes(g.id));
const probed = shown.filter(g => hitTexts.some(h => h.indexOf(g.id) >= 0));
const pure = shown.filter(g => !probed.includes(g));
html += `<details><summary><b>${esc(cat.category)}</b>:${cat.total} 实体 / 关联 ${cat.matched} / 缺口 ${shown.length}(其中探针承载 ${probed.length})</summary><div class='gaplist'>`;
for (const g of probed) html += `${lamp("ok", "探针真输入命中承载")} ${esc(g.id)} <span class="note">(断言文料零关联——交互覆盖由探针承载)</span><br>`;
for (const g of pure) html += `${lamp("off", "验证零关联缺口——人工复核后再立事项")} ${esc(g.id)}<br>`;
html += "</div></details>";
}
document.getElementById("ontologyGaps").innerHTML = html;
})();
</script>
</body>
</html>
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""extract_capability_list.py — 能力清单义务层提取器(B 覆盖总览面板·义务层)
把 game/tests/README.md「能力清单」的四列表(能力|系统|真实入口|代表用例)按旅程段
解析成机器可读 JSONP .js,供 tools/coverage_report.html 经 <script src> 直载
(file:// 下 fetch 本地 json 被 CORS 拦——JSONP 是 B 面板全数据源的统一形态)。
用法:
python tools/extract_capability_list.py [--readme <路径>] [--out <路径>]
判据口径:
- 只认表头恰为「能力|系统|真实入口|代表用例」的四列表(覆盖域表等五列表不取);
- 旅程段标题形态=「**旅程段N:名称**」,段名进每行的 segment 字段;
- 代表用例列按「;」切分成用例指针数组(`文件:函数` 形态逐字保真);
- 本工具是义务层的静态投影,产出随 README 变更重跑即得——面板只读本产物,禁在面板里改义务。
产出(JSONP):
window.__QA_COVERAGE__.capability_list = {
"meta": {"generated_at", "source", "rows_total", "segments_total"},
"segments": [{"segment": 名称, "rows": [{"ability", "system", "entry", "cases": []}]}],
}
"""
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_README = ROOT / "game" / "tests" / "README.md"
DEFAULT_OUT = ROOT / "game" / "tests" / ".gates" / "coverage" / "capability_list.js"
SEGMENT_RE = re.compile(r"^\*\*旅程段[一二三四五六七八九十]+[::](.+?)\*\*\s*$")
def split_row(line: str) -> list[str]:
body = line.strip()
if body.startswith("|"):
body = body[1:]
if body.endswith("|"):
body = body[:-1]
return [c.strip() for c in body.split("|")]
def parse(readme: Path) -> dict:
lines = readme.read_text(encoding="utf-8").splitlines()
segments: list[dict] = []
cur_seg: str | None = None
cur_rows: list[dict] | None = None
in_capability_table = False
for line in lines:
seg = SEGMENT_RE.match(line.strip())
if seg:
if cur_seg is not None:
segments.append({"segment": cur_seg, "rows": cur_rows or []})
cur_seg = seg.group(1).strip()
cur_rows = []
in_capability_table = False
continue
if cur_seg is None:
continue
if not line.strip().startswith("|"):
in_capability_table = False
continue
cells = split_row(line)
if all(not set(c) - {"-", " ", ":"} for c in cells) and cells:
continue # 表头分隔行 |---|---|
if (len(cells) == 4 and cells[0] == "能力" and cells[1] == "系统"
and cells[2] == "真实入口" and cells[3].startswith("代表用例")):
in_capability_table = True
continue
if in_capability_table and len(cells) == 4:
ability, system, entry, cases = cells
cur_rows.append({
"ability": ability.strip("`"),
"system": system,
"entry": entry,
"cases": [c.strip().strip("`") for c in cases.split(";") if c.strip()],
})
if cur_seg is not None:
segments.append({"segment": cur_seg, "rows": cur_rows or []})
rows_total = sum(len(s["rows"]) for s in segments)
return {
"meta": {
"generated_at": datetime.datetime.now().isoformat(timespec="seconds"),
"source": str(readme),
"rows_total": rows_total,
"segments_total": len(segments),
},
"segments": segments,
}
def dump_js(payload: dict, out: Path) -> None:
out.parent.mkdir(parents=True, exist_ok=True)
js = "/* 能力清单义务层投影——由 extract_capability_list.py 生成,随 README 变更重跑;人工修订无效(义务源=README 能力清单)。 */\n"
js += "window.__QA_COVERAGE__ = window.__QA_COVERAGE__ || {};\n"
js += "window.__QA_COVERAGE__.capability_list = %s;\n" % json.dumps(payload, ensure_ascii=False, indent=2)
out.write_text(js, encoding="utf-8", newline="\n")
def main() -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
ap = argparse.ArgumentParser(description="能力清单义务层提取(B 面板·义务层)")
ap.add_argument("--readme", default=str(DEFAULT_README))
ap.add_argument("--out", default=str(DEFAULT_OUT))
args = ap.parse_args()
readme = Path(args.readme)
if not readme.is_file():
print("[capability] README 不存在:%s" % readme)
return 1
payload = parse(readme)
dump_js(payload, Path(args.out))
print("[capability] 义务层投影已落盘 %s(旅程段 %d / 能力行 %d)" % (
args.out, payload["meta"]["segments_total"], payload["meta"]["rows_total"]))
if payload["meta"]["rows_total"] == 0:
print("[capability] 红:能力行数为 0——表头或旅程段标题形态不匹配,禁当空义务面用")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -648,5 +648,7 @@ RC=$?
echo ""
echo "════════════════════════════════════════════"
printf ' 回执:%s\n 基线:%s\n commit:%s\n' "$RECEIPT" "$BASELINE" "$(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '?')"
# B 覆盖总览面板·执行层六源明细化(观测件非判定件——解析失败打红字不翻闸判,coverage_receipts.py 头注)
"$PY" "$REPO/tools/coverage_receipts.py" --stamp-dir "$STAMP_DIR" || true
echo "════════════════════════════════════════════"
exit $RC