2026-10-15 03:31:20 +08:00
|
|
|
|
# -*- 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"],
|
2026-10-15 04:31:31 +08:00
|
|
|
|
"00_data.js": ["webapp/generated"],
|
|
|
|
|
|
"sheet_front.svg": ["docs"],
|
|
|
|
|
|
"sheet_joint.svg": ["docs"],
|
|
|
|
|
|
"sheet_exploded.svg": ["docs"],
|
|
|
|
|
|
"sheet_side.svg": ["docs"],
|
2026-10-15 03:31:20 +08:00
|
|
|
|
"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)
|
2026-10-15 04:31:31 +08:00
|
|
|
|
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 = []
|
2026-10-15 03:31:20 +08:00
|
|
|
|
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:
|
2026-10-15 04:31:31 +08:00
|
|
|
|
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)
|
2026-10-15 03:31:20 +08:00
|
|
|
|
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())
|