Files
jiu/tools/fidelity.mjs
T
wangjia 6641517104 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
2026-07-01 20:46:52 +08:00

131 lines
6.3 KiB
JavaScript
Raw 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.
#!/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);