# -*- coding: utf-8 -*- """重构期的不变量关卡。 python -m tests.verify # 数据哈希 + IK 数值(秒级,每步都跑) python -m tests.verify --shots # 再加全套截图比对(约 3 分钟,前端改动后跑) 基线在 tests/baselines/ 下,随仓库走。产物按**文件名**匹配,不写死目录: mesh 从 web/ 搬到 webapp/data/ 这类移动不该让关卡失效,也不该让我以为它通过了。 任何一项对不上就退出码 1。 """ import hashlib import io import json import os import subprocess import sys sys.stdout.reconfigure(encoding="utf-8") HERE = os.path.dirname(os.path.abspath(__file__)) REPO = os.path.dirname(HERE) BASE = os.path.join(HERE, "baselines") # 每个产物允许出现的目录,按优先级;找到第一个即取。 CANDIDATES = { "mesh_arm.json": ["webapp/data", "web"], "00_data.js": ["webapp/generated"], "sheet_front.svg": ["docs"], "sheet_joint.svg": ["docs"], "sheet_exploded.svg": ["docs"], "sheet_side.svg": ["docs"], "travel.json": ["docs", "webapp/data"], "travel_detail.json": ["docs", "webapp/data"], "collide.json": ["docs", "webapp/data"], "screws.json": ["docs"], } def md5(path): h = hashlib.md5() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def read_list(name): """基线文件是 'hash filename';md5sum 的二进制模式会在文件名前加 '*',剥掉。""" out = {} for ln in io.open(os.path.join(BASE, name), encoding="utf-8"): ln = ln.strip() if ln: h, f = ln.split() out[f.lstrip("*")] = h return out def locate(fname, dirs): for d in dirs: p = os.path.join(REPO, d, fname) if os.path.exists(p): return p return None def check_data(): base = read_list("data_by_name.md5") bad, rows = 0, [] for fname, dirs in CANDIDATES.items(): want = base.get(fname) if want is None: rows.append(("SKIP", fname, "基线里没有这项")) continue p = locate(fname, dirs) if p is None: bad += 1 rows.append(("MISS", fname, "找不到文件(试过的目录: %s)" % ", ".join(dirs))) continue got = md5(p) ok = got == want if not ok: bad += 1 rows.append(("OK " if ok else "DIFF", fname, os.path.relpath(p, REPO).replace("\\", "/") + " " + got[:12] + ("" if ok else " 基线 " + want[:12]))) for st, f, msg in rows: print(" [%s] %-20s %s" % (st, f, msg)) return bad def check_ik(): r = subprocess.run(["node", os.path.join(HERE, "ik_baseline.mjs"), "--compare", os.path.join(BASE, "ik_exact.json")], capture_output=True, text=True, cwd=REPO, encoding="utf-8", errors="replace") out = r.stdout or "" for ln in out.splitlines(): print(" " + ln) if r.returncode != 0: print(" node 退出码 %d: %s" % (r.returncode, (r.stderr or "").strip()[:300])) # 没有 "== exact" 这一行就说明探针根本没跑出比对结果(编码炸了、node 不在、路径错…), # 绝不能因为"没报差异"就算通过。 if "== exact" not in out: print(" [MISS] ik_baseline 没有输出比对结果,判 FAIL") return 1 return 0 if r.returncode == 0 else 1 def check_shots(): """两层判定,依据是"同一构建连跑两轮"的实测(scratch/_gate/flaky_scan.py): 104 张逐字节可复现 -> md5 必须相同; 3 张线束 x-ray 不可复现(mean<=0.0017、无像素差>8,纯抗锯齿噪声) -> 与 tests/baselines/flaky/ 里的参考像素比,超阈值才算回归。 把不可复现的也要求 md5 相同,等于给每一步埋假 FAIL,最后只会被人肉忽略。""" import numpy as np from PIL import Image base = read_list("shots_stable.md5") th = json.load(io.open(os.path.join(BASE, "flaky", "thresholds.json"), encoding="utf-8")) r = subprocess.run([sys.executable, "-m", "arm.viewer.shot"], capture_output=True, text=True, cwd=REPO, encoding="utf-8", errors="replace") out = r.stdout or "" if "all shots ok" not in out and "OK " not in out: print(" [MISS] shot.py 没有正常输出(退出码 %s),判 FAIL" % r.returncode) return 999 bad = [l for l in out.splitlines() if "FAIL" in l] if bad: print(" [MISS] 截图失败 %d 个: %s" % (len(bad), bad[:3])) fails = len(bad) def shoot(names): subprocess.run([sys.executable, "-u", "-m", "arm.viewer.shot"] + names, capture_output=True, text=True, cwd=REPO, encoding="utf-8", errors="replace") diff = [] for fname, want in sorted(base.items()): p = os.path.join(REPO, fname.replace("/", os.sep)) if not os.path.exists(p): print(" [MISS] %s" % fname) fails += 1 continue if md5(p) != want: diff.append(fname) if diff: # 偶发"半加载截图"确实存在(实测同一构建重跑就回到基线)。串行重拍一次: # 真回归重拍也回不来,只有竞态会消失 —— 不重拍就会变成随机假 FAIL, # 关卡一旦不可信就会被忽略。 print(" %d 张首轮不一致,串行重拍复核: %s" % (len(diff), [os.path.basename(x) for x in diff])) shoot([os.path.basename(x).replace(".png", "") for x in diff]) for fname in diff: p = os.path.join(REPO, fname.replace("/", os.sep)) if md5(p) != base[fname]: print(" [DIFF] %-40s 重拍后仍不一致" % fname) fails += 1 else: print(" [RETRY-OK] %-36s 竞态,非回归" % fname) print(" 逐字节比对: %d 张稳定视图,%d 张不一致" % (len(base), fails - len(bad))) tol_fails = 0 for name, ref in sorted(th["views"].items()): p = os.path.join(REPO, "docs", "shots", name + ".png") q = os.path.join(BASE, "flaky", name + ".png") if not (os.path.exists(p) and os.path.exists(q)): print(" [MISS] 容差比对缺文件: %s" % name) tol_fails += 1 continue d = np.abs(np.asarray(Image.open(p).convert("RGB"), dtype=np.int16) - np.asarray(Image.open(q).convert("RGB"), dtype=np.int16)) mean, mx = float(d.mean()), int(d.max()) over = mean > th["mean_max"] or mx > th["pixel_max"] tol_fails += 1 if over else 0 print(" [%s] %-34s mean=%.5f (<= %.2f) 最大单像素差=%d (<= %d)" % ("DIFF" if over else "OK ", name, mean, th["mean_max"], mx, th["pixel_max"])) print(" 容差比对: %d 张噪声视图,%d 张超阈值" % (len(th["views"]), tol_fails)) return fails + tol_fails def main(): argv = sys.argv[1:] fails = 0 print("== 数据产物哈希 ==") fails += check_data() print("== IK 数值基线 ==") fails += check_ik() if "--shots" in argv: print("== 截图逐字节比对 ==") fails += check_shots() print("RESULT:", "PASS" if fails == 0 else "FAIL(%d 项不一致)" % fails) return 1 if fails else 0 if __name__ == "__main__": sys.exit(main())