#!/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 已是 NotoSansSC(pubspec 打包)。 * ② 阈值逐屏校准(密表/外壳噪声不同),抓「缺顶栏/缺卡片/错列」类结构性差异; * 像素级细节靠 ds-compare 人工 montage 目检兜底。 * * 前置:先生成对应 Flutter golden: * cd client && flutter test --update-goldens test/golden/_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/_.png 原型基准(入库,prototype 变更时刷新) * design/_fidelity/__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 server(root=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; } ran++; if (s.zones && s.zones.length) { // 分区域判定:全屏 diff 仅参考(不判定,避免内容区差异淹没局部错位), // 真正判定逐 zone(顶栏/侧栏/状态栏各自紧阈),抓全屏百分比看不到的局部偏移。 try { execFileSync('node', [diff, proto, golden, out, '--threshold', '1', '--tolerance', values.tolerance], { stdio: 'pipe' }); } catch { /* 全屏仅参考 */ } let zoneFail = 0; for (const z of s.zones) { const zout = join(diffDir, `${s.prefix}_${theme}_${z.name}_diff.png`); const crop = [z.x * s.dpr, z.y * s.dpr, z.w * s.dpr, z.h * s.dpr].join(','); try { execFileSync('node', [diff, proto, golden, zout, '--threshold', String(z.threshold), '--tolerance', values.tolerance, '--crop', crop], { stdio: 'inherit' }); console.log(`[fidelity] ✅ ${name}·${theme}·${z.name} ≤ ${(z.threshold * 100).toFixed(1)}%`); } catch { console.error(`[fidelity] ❌ ${name}·${theme}·${z.name} 超阈 ${(z.threshold * 100).toFixed(1)}% — 看 ${zout}`); zoneFail++; } } if (zoneFail) failed++; } else { // ③ 整屏 diff,超「该屏校准阈值」即红 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);