Files
cbb 531a293688 refactor(viewer): 单文件查看器拆成 webapp/ 多文件,走本地 http;Worker 换成真文件
前端不再是"一个 16MB HTML + 一个 2955 行模板"。现在:
  webapp/index.html      343 行 DOM 骨架,零内联数据
  webapp/styles/*.css    811 行 CSS 按段切成 8 个文件
  webapp/js/app.js       原内联主脚本逐字搬出(仍是普通脚本,S5 才改 ESM)
  webapp/js/ik-solver.js / ik-glue.js / vendor/three.min.js(原 web/ 三个文件)
  webapp/workers/ik.worker.js  真 Worker 文件
  webapp/generated/00_data.js  生成物(网格/工程图/行程/碰撞/螺钉)
  dist/机器人手臂3D查看器.html  分发用单文件,由 build_viewer 内联上面这些文件产出

入口:python -m arm.viewer.serve 后开 http://127.0.0.1:8123/
(serve 带 no-store,改完刷新即新构建,不再需要 Ctrl+F5;显式 MIME,
 .js 被 Windows 注册表判成 text/plain 会让模块静默不执行)

删掉 src/viewer_tpl.html 与 web/ 目录(旧产物移入 backups/pre_split_20261015/)。
工程图不再维护第二份 sheets.jsdata —— 直接读 draw_sheet 写出的 4 个真 SVG。

实测中修掉的问题:
- serve 的 / 原先"内部映射"到 webapp/index.html,导致文档基址是 /、相对路径的
  8 个 CSS 与 4 个 JS 全部 404(整页无样式无脚本)。改成 302 跳转。
- file:// 下 new Worker('workers/…') 构造阶段不抛错、错误以异步 onerror 到达,
  所以"构造失败才试 Blob 兜底"永远不会触发;改为按协议选路。两条路径都已实测
  backend=worker(http 走文件、file:// 走 Blob)。
- Worker 握手 3s 在 headless 并发下会误判"没起来"(截图里红徽标即此),放宽到 10s,
  并把后端状态暴露成 window.__IK_BACKEND / __IK_BACKEND_LOG 供测试直读。
- 截图竞态:4 并发 × 每页 14MB 数据时,虚拟时间预算会在页面加载完成前到期,
  拍到半初始化画面(且能"稳定地坏",旧基线里 m9_motion 就是这么混进来的)。
  并发降到 2、预算 40s、serve 加 HTTP/1.1+大缓冲+字节缓存,并用"同一构建两轮"
  重新界定稳定性:99 张逐字节一致、6 张属噪声改走像素容差(mean<=0.02、单像素<=32)。
- 关卡自身两处假通过已修:node 输出被 GBK 解码炸掉时不再算 PASS;
  线束干涉测试读不到通道源码时会 exit 2,而不是"零通道=零穿透=通过"。

关卡结果:python -m arm.build_model 退出码 0;10 项数据哈希与基线一致;
IK exact 段逐字节一致;99 张截图逐字节一致 + 6 张在噪声阈内;线束干涉测试 1800 点 0 穿透。
2026-10-15 04:31:31 +08:00

195 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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())