Files
jiu/tools/fidelity.mjs
wangjia 15d825125d feat(tools): fidelity 加分区域阈值(抓全屏百分比淹没的局部错位)
针对「顶栏右组错位全屏 diff 3.5% 没报红」的盲区:
- screens.mjs shell 加 zones(顶栏/侧栏/状态栏逻辑坐标 + 各自紧阈,顶栏收到 4%)
- fidelity.mjs:有 zones 时全屏仅参考,逐 zone 判定(按 dpr crop)
- skill diff.mjs 加 --crop 仅比矩形区域(全局工具,不在本 repo)
- 验证:错位版 golden 顶栏带 9.48% > 4% 报红;修正版 3.16% 通过

仍存盲区:弹出态(用户菜单/dropdown/dialog)不进静态 golden,需单独补浮层 golden。

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

141 lines
6.7 KiB
JavaScript
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.
#!/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;
}
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);