Files
jiu/tools/fidelity.mjs
T
wangjia ffc71c8894 feat(tools): Phase 0.3 ds-compare 注册表驱动(每屏+外壳都跟原型比)
- 抽出 tools/screens.mjs 屏注册表单一真源,fidelity 与 ds-compare 共用
- ds-compare 改为遍历注册表(含 shell 外壳项),默认全屏×三主题产并排目检图;
  保留 --html/--prefix 临时单屏;统一注入 NotoSansSC 与 golden 同字体
- 满足规则2:每一屏 + 外壳都需跟原型对比

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
2026-06-25 17:47:07 +08:00

117 lines
5.5 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';
import { SCREENS } from './screens.mjs'; // 屏注册表单一真源(与 ds-compare 共用)
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);