feat(tools): Phase 0.2 fidelity 保真闸(Flutter golden vs 原型,替掉 golden 自比)

- tools/fidelity.mjs:逐屏注册表 → 注入统一字体截原型基准 → 与 Flutter golden
  pixelmatch diff,超逐屏校准阈值即红。这才是真·跟原型比的保真证据。
- design-distill skill shoot-prototype.mjs 加 --inject-css(注入字体覆盖 --font)
- 验证:inventory 当前屏 vs 原型 diff=28.4% > 阈,立刻抓到「缺整个外壳」——
  正是旧 golden 自比(永远 0%)发现不了的结构性差异
- 原型基准入库 client/test/golden/proto/;差异图 design/_fidelity/ 进 gitignore

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
This commit is contained in:
wangjia
2026-06-25 17:43:18 +08:00
parent 243b37e81d
commit 6641517104
3 changed files with 132 additions and 0 deletions
+2
View File
@@ -74,3 +74,5 @@ backend/gencode
# design-distill 保真目检对比图(可重生,见 tools/ds-compare.mjs
design/_compare/
# fidelity diff 差异图(可重生,见 tools/fidelity.mjs);原型基准 proto/ 仍入库
design/_fidelity/
Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* fidelity.mjs — 还原保真闸:Flutter golden vs 原型(真·跟原型比,替掉 golden 自比)
*
* 背景:Flutter golden 是「自己渲染→存基准→再渲染→比基准」,结构上永远发现不了
* 「跟原型长得不一样」。本脚本把每屏的 **原型截图当基准**,与 **Flutter golden** 做
* pixelmatch diff,超「逐屏校准阈值」即红 —— 这才是真正的保真证据。
*
* 跨渲染器(原型 Chromium ↔ Flutter Skia)做不到 0% 像素,故:
* ① 两边统一字体:原型注入 Noto Sans SC 覆盖 --font/--font-mono(见 _inject CSS),
* Flutter 侧 ThemeData.fontFamily 已是 NotoSansSCpubspec 打包)。
* ② 阈值逐屏校准(密表/外壳噪声不同),抓「缺顶栏/缺卡片/错列」类结构性差异;
* 像素级细节靠 ds-compare 人工 montage 目检兜底。
*
* 前置:先生成对应 Flutter golden
* cd client && flutter test --update-goldens test/golden/<x>_golden_test.dart
*
* 用法(仓库根运行):
* node tools/fidelity.mjs # 跑注册表里所有屏 × 所有主题
* node tools/fidelity.mjs inventory # 只跑某屏
* node tools/fidelity.mjs inventory --themes a # 指定主题
* node tools/fidelity.mjs --update # 仅刷新原型基准图(proto/),不判定
*
* 产物:
* client/test/golden/proto/<prefix>_<theme>.png 原型基准(入库,prototype 变更时刷新)
* design/_fidelity/<prefix>_<theme>_diff.png 差异图(gitignore,可重生)
*
* 退出码:任一屏×主题超阈 → 1(可作 pre-commit / CI 闸)。
*/
import { parseArgs } from 'node:util';
import { execFileSync } from 'node:child_process';
import { mkdirSync, existsSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
// ── 逐屏注册表 ────────────────────────────────────────────────────────────────
// width/height = golden 的「逻辑」尺寸(golden 物理像素 = 逻辑 × dpr);两边须一致。
// prefix = golden 文件前缀 client/test/golden/goldens/<prefix>_<theme>.png。
// threshold = 该屏校准阈值(差异像素比例上限);首版人工目检「像」后把当时 diff% + 余量记此。
// 注:屏随 Phase 2 逐个照原型重建后再把其 prefix 入册并校准阈值。
const SCREENS = {
inventory: {
html: '.superpowers/prototype/screens/inventory.html',
prefix: 'inventory_list',
width: 1280, height: 900, dpr: 2,
waitFor: '.app',
threshold: 0.20, // 旧屏未重建,暂宽松;Phase 2 重建后收紧
},
};
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
themes: { type: 'string', default: 'a,b,c' },
update: { type: 'boolean', default: false }, // 仅刷新原型基准,不判定
tolerance: { type: 'string', default: '0.1' },
help: { type: 'boolean', default: false },
},
});
if (values.help) {
console.log(`用法: node tools/fidelity.mjs [screen] [--themes a,b,c] [--update] [--tolerance 0.1]\n已注册屏: ${Object.keys(SCREENS).join(', ')}`);
process.exit(0);
}
const SKILL = process.env.DD_SKILL || join(homedir(), '.claude/skills/design-distill');
const shoot = join(SKILL, 'tools/shoot-prototype.mjs');
const diff = join(SKILL, 'tools/diff.mjs');
for (const t of [shoot, diff]) {
if (!existsSync(t)) { console.error(`[fidelity] 找不到 skill 工具: ${t}(设 DD_SKILL 覆盖)`); process.exit(2); }
}
const protoDir = 'client/test/golden/proto';
const diffDir = 'design/_fidelity';
mkdirSync(protoDir, { recursive: true });
mkdirSync(diffDir, { recursive: true });
// 统一字体注入:把原型的 --font/--font-mono 覆盖为 NotoSansSC(与 Flutter golden 同字体)。
// 字体经 shoot 临时 http serverroot=cwd)按 /client/... 路径供给。
const injectCss = join(tmpdir(), 'jiu-fidelity-font.css');
writeFileSync(injectCss, [
`@font-face{font-family:'NotoSansSC';src:url('/client/assets/fonts/NotoSansSC.ttf');font-weight:100 900;font-display:block;}`,
`:root{--font:'NotoSansSC',sans-serif!important;--font-mono:'NotoSansSC',monospace!important;}`,
`*{font-family:'NotoSansSC',sans-serif!important;}`,
].join('\n'));
const wantThemes = values.themes.split(',').map((s) => s.trim()).filter(Boolean);
const targets = positionals.length ? positionals : Object.keys(SCREENS);
let failed = 0, ran = 0;
for (const name of targets) {
const s = SCREENS[name];
if (!s) { console.error(`[fidelity] 未注册屏: ${name}(有: ${Object.keys(SCREENS).join(', ')}`); failed++; continue; }
for (const theme of wantThemes) {
const proto = join(protoDir, `${s.prefix}_${theme}.png`);
const golden = resolve('client/test/golden/goldens', `${s.prefix}_${theme}.png`);
const out = join(diffDir, `${s.prefix}_${theme}_diff.png`);
// ① 原型基准图(注入统一字体)
execFileSync('node', [shoot, s.html, proto,
'--width', String(s.width), '--height', String(s.height), '--dpr', String(s.dpr),
'--theme', theme, '--lang', 'zh', '--wait-for', s.waitFor,
'--inject-css', injectCss, '--wait', '1200'],
{ stdio: 'inherit' });
if (values.update) { console.log(`[fidelity] 🔄 刷新基准 ${proto}`); continue; }
// ② Flutter golden 必须已生成
if (!existsSync(golden)) {
console.error(`[fidelity] ⚠ 缺 Flutter golden: ${golden} —— 先 flutter test --update-goldens。判定失败。`);
failed++; continue;
}
// ③ diff,超「该屏校准阈值」即红
ran++;
try {
execFileSync('node', [diff, proto, golden, out,
'--threshold', String(s.threshold), '--tolerance', values.tolerance],
{ stdio: 'inherit' });
console.log(`[fidelity] ✅ ${name}·${theme} ≤ 阈值 ${(s.threshold * 100).toFixed(1)}%`);
} catch {
console.error(`[fidelity] ❌ ${name}·${theme} 超阈 ${(s.threshold * 100).toFixed(1)}% — 看 ${out}`);
failed++;
}
}
}
if (values.update) { console.log(`[fidelity] 基准刷新完成。`); process.exit(0); }
console.log(`\n[fidelity] 跑了 ${ran} 屏×主题,失败 ${failed}`);
process.exit(failed ? 1 : 0);