test(scripts): 36 个 E2E 与 6 个机械校验工具(1548 条断言)+ 一键交付自检
新增:check-api-parity(契约 121 接口 ↔ 控制器,自带「必须恰好 121」自检)、 check-entity-columns(基类要求的列 vs 迁移建表列,F-35 那个坑踩了四次之后上的工具)、 check-manual(手册引用的错误码与提示文案对得上代码)、check-test-profile(test profile 真起一次)、 e2e-prodguard、load-test(四场景、按端点分别统计)、verify-all(一条命令跑完交付自检)、 dev-seed-audit(界面验收用的演示数据),以及 M1~M9 各轮 E2E。 压测抓到一个真缺陷并修掉(F-92):门禁 IP 限流原来计「所有请求」,于是一个班连同一个 WiFi 时 60 个不同学生同时核验只过了 20 个 → 改为只计失败 + 单独的成功上限,20/60 → 60/60。 度量工具的偏差会伪装成产品缺陷(F-60/F-77/F-97),所以每个脚本都断言「动作自己的结论字段」 (submitted / created / canExport),而不是 HTTP 200 + code 0。 验证:verify-all.py --quick 5/5;--with-e2e 见 docs/09 §8。
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""契约 ↔ 代码 接口实现度比对(可复现的盘点工具)。
|
||||
|
||||
用途:回答"121 个契约接口里,哪些已经实现、哪些还没做"。
|
||||
—— 这个数在《08-完成度盘点与问题清单.md》里被引用,必须可复现、不能靠记忆。
|
||||
|
||||
契约来源:docs/03-接口设计.md 的 §4 接口总览(合计 121 个,全文权威清单)。
|
||||
代码来源:backend 所有 controller 的类级 @RequestMapping + 方法级 @*Mapping。
|
||||
|
||||
★ 两个必须容忍的写法(踩过):
|
||||
1. 全限定注解 `@org.springframework.web.bind.annotation.PostMapping("/x")`
|
||||
—— FillController 就是这么写的;用 `@PostMapping` 短名匹配会漏掉 3 个接口;
|
||||
2. `@RequestMapping(value="/x", method=RequestMethod.POST)` 这种显式写法。
|
||||
|
||||
用法:
|
||||
python scripts/check-api-parity.py # 打印逐分类实现度 + 缺口清单
|
||||
python scripts/check-api-parity.py --quiet # 只打印汇总(CI 里用)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DOC = os.path.join(ROOT, "docs", "03-接口设计.md")
|
||||
JAVA = os.path.join(ROOT, "backend", "xt-server", "src", "main", "java", "cn", "tianbaotong", "fill")
|
||||
|
||||
# §4 里这两节是索引(交叉引用别处已计数的接口),不重复计入
|
||||
INDEX_SECTIONS = ("4.9", "4.11")
|
||||
|
||||
|
||||
def norm(path: str) -> str:
|
||||
"""归一化:去掉 /api/v1 前缀、统一占位符(`{id}` 与 `{fileId}` 视为同一个)。"""
|
||||
p = path.replace("/api/v1", "")
|
||||
p = re.sub(r"\{[^}]+\}", "{}", p)
|
||||
return p.rstrip("/") or "/"
|
||||
|
||||
|
||||
def doc_overview() -> dict[str, list[tuple[str, str]]]:
|
||||
"""解析 §4.1~§4.10 的清单表,返回 分类 -> [(METHOD, path)]。"""
|
||||
text = open(DOC, encoding="utf-8").read()
|
||||
sec = text[text.index("## §4"):text.index("## §5")]
|
||||
out: dict[str, list[tuple[str, str]]] = {}
|
||||
cur = None
|
||||
for line in sec.splitlines():
|
||||
h = re.match(r"^### (4\.\d+) (.+)$", line)
|
||||
if h:
|
||||
cur = f"{h.group(1)} {h.group(2)}"
|
||||
out[cur] = []
|
||||
continue
|
||||
if cur is None or not line.startswith("|") or cur.startswith(INDEX_SECTIONS):
|
||||
continue
|
||||
cells = [c.strip() for c in line.strip("|").split("|")]
|
||||
if len(cells) < 2 or set(cells[0]) <= set("-: "):
|
||||
continue
|
||||
method, path = cells[0].replace("⭐", "").strip(), cells[1].strip("` ")
|
||||
if not path.startswith("/"):
|
||||
continue
|
||||
for m in re.findall(r"GET|POST|PUT|DELETE|PATCH", method.upper()) or ["?"]:
|
||||
out[cur].append((m, path))
|
||||
return out
|
||||
|
||||
|
||||
def code_endpoints() -> list[tuple[str, str, str]]:
|
||||
"""解析所有 controller,返回 [(文件, METHOD, 完整路径)]。"""
|
||||
out = []
|
||||
for f in glob.glob(os.path.join(JAVA, "module", "**", "controller", "*.java"), recursive=True):
|
||||
src = open(f, encoding="utf-8").read()
|
||||
mb = re.search(r'@RequestMapping\(\s*(?:value\s*=\s*)?"([^"]+)"', src)
|
||||
base = mb.group(1) if mb else ""
|
||||
for mm in re.finditer(
|
||||
r'@(?:[\w.]+\.)?(Get|Post|Put|Delete|Patch)Mapping(?:\(\s*(?:value\s*=\s*)?"([^"]*)")?',
|
||||
src):
|
||||
out.append((os.path.basename(f), mm.group(1).upper(),
|
||||
(base + (mm.group(2) or "")) or "/"))
|
||||
for mm in re.finditer(
|
||||
r'@RequestMapping\(\s*value\s*=\s*"([^"]+)"\s*,\s*method\s*=\s*'
|
||||
r'(?:RequestMethod\.)?(GET|POST|PUT|DELETE|PATCH)', src):
|
||||
out.append((os.path.basename(f), mm.group(2), base + mm.group(1)))
|
||||
return sorted(set(out), key=lambda x: (x[0], x[2]))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
quiet = "--quiet" in sys.argv
|
||||
only_missing = "--missing" in sys.argv
|
||||
doc = doc_overview()
|
||||
code = code_endpoints()
|
||||
code_keys = {(m, norm(p)) for _, m, p in code}
|
||||
|
||||
claimed = implemented = 0
|
||||
missing: list[tuple[str, str, str]] = []
|
||||
rows = []
|
||||
for cat, eps in doc.items():
|
||||
uniq = sorted(set(eps))
|
||||
done = [e for e in uniq if (e[0], norm(e[1])) in code_keys]
|
||||
miss = [e for e in uniq if (e[0], norm(e[1])) not in code_keys]
|
||||
claimed += len(uniq)
|
||||
implemented += len(done)
|
||||
missing += [(cat, m, p) for m, p in miss]
|
||||
rows.append((cat, len(uniq), len(done), len(miss)))
|
||||
|
||||
if not quiet and not only_missing:
|
||||
print("=" * 78)
|
||||
print("代码注册接口:%d 个" % len(code))
|
||||
print("=" * 78)
|
||||
for f, m, p in code:
|
||||
print(" %-30s %-6s %s" % (f, m, p))
|
||||
print()
|
||||
|
||||
print("=" * 78)
|
||||
print("契约 §4 总览逐分类")
|
||||
print("=" * 78)
|
||||
for cat, n, d, miss_n in rows:
|
||||
print(" %-22s 声称 %2d 已实现 %2d 缺口 %2d" % (cat, n, d, miss_n))
|
||||
|
||||
print()
|
||||
print("合计:契约 %d / 已实现 %d / 缺口 %d / 实现度 %.0f%%"
|
||||
% (claimed, implemented, len(missing), 100.0 * implemented / claimed))
|
||||
|
||||
if not quiet:
|
||||
print()
|
||||
cur = None
|
||||
for cat, m, p in missing:
|
||||
if cat != cur:
|
||||
print("\n【%s】" % cat)
|
||||
cur = cat
|
||||
print(" %-6s %s" % (m, p))
|
||||
# `--missing` 就是"只看还差什么":上面那两段(代码清单 + 逐分类数字)
|
||||
# 在"接着往下做"时反而是噪声,而这是最常被问的问题
|
||||
if only_missing:
|
||||
print()
|
||||
print("说明:--missing 只列缺口;不带参数时会先打全部已注册接口。")
|
||||
|
||||
# 契约必须恰好 121(§2 的口径),不等就说明解析或文档出了问题
|
||||
if claimed != 121:
|
||||
print(" [FAIL] 契约接口数应为 121,实际解析出 %d —— 检查 §4 表格格式" % claimed)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user