单文件约束取消后,Python 侧没必要再靠 16 处 sys.path.insert 维持扁平模块互认。
现在按职责分包:
arm/paths.py 路径唯一来源(含新增 CACHE、TPL)
arm/constants.py 实测接口数值(原 armcore 的 M/C/CLR/FIT/MOTOR_TOL 段)
arm/cad/{helpers,arm_build,part_tools,parts/} 几何与装配
arm/kinematics.py(原 human_layout.py,唯一运动学事实源)
arm/analysis/{sweep_v2,strength_check,torque_balance,finalize_travel,ws_sweep,gen_env_mesh}
arm/dataio/{screw_mesh,draw_sheet} arm/viewer/{build_viewer,shot}
arm/build_model.py(原 arm_model.py)
顺带修掉的既有缺陷(都是实测撞出来的,不是预防性改动):
- arm_model 第二次 sys.path 跳到 gitignore 的 scratch/ 去 import 构建必需的包络脚本:
ws_sweep/gen_env_mesh 提升进 arm/analysis/,缓存挪到 data/cache/(gitignore,可重生成)。
- finalize_travel / draw_sheet 顶层就是"算完写文件",一次 importlib 冒烟测试就把
docs/travel.json 重写了一遍;现在被 import 直接抛错。
- strength_check 用 OUT.replace("/out","/web") 靠字符串形状推路径,改成直接用 WEB。
- build_viewer 里 WORKSPACE 使用点漏改导致 NameError —— 已由构建关卡抓到并修掉。
os._exit 的处置与计划里的假设相反:实测光是 `python -c "import arm.cad.helpers"`
就会在解释器退出时段错误(exit 139,OCC 静态析构),全流程跑完也一样。所以入口
脚本尾部保留 flush + os._exit,并把原因写进注释;被 import 时不生效。
关卡(tests/):
- verify.py 一条命令跑三层:产物 md5、IK 数值 exact 段、截图。
- 截图基线重做并分级:同一构建连跑两轮实测 102 张逐字节可复现(要求 md5 相同),
3 张线束 x-ray 不可复现(mean<=0.0017、无像素差>8)改为与参考像素比容差;
这 3 张不再入库,免得每次跑关卡都脏 git。
- 新增 ?view=sheet / ?sheet=N / ?theme / ?guide=N 四个 URL 参数:工程图与装配向导
占了约一半 CSS 和 700 行 JS,原先没有任何截图覆盖,拆它们等于没有关卡。
- 关卡灵敏度已验:改坏 IK 基线一个数 -> FAIL;换旧版求解器 -> 5 处 exact 差异。
实测:python -m arm.build_model 退出码 0,全部产物 md5 与基线一致,
105 视图截图全过,IK exact 段逐字节一致。
173 lines
6.3 KiB
Python
173 lines
6.3 KiB
Python
# -*- 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"],
|
||
"sheets.jsdata": ["webapp/data", "web"],
|
||
"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)
|
||
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:
|
||
print(" [DIFF] %-40s" % fname)
|
||
fails += 1
|
||
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())
|