feat(tests): caption posted screenshots (name + spec); raise drift floor to 0.5%
Bake a caption strip onto every posted screenshot composite — status + name + the spec that produces it — for changed, added, and removed; removed now shows the old baseline image (was a text-only line). Zero native-dep: an embedded public-domain 8x8 bitmap font (font8x8.ts) rendered by image-ops `withBottomLabel`; the name→spec attribution is factored out of gen-manifest into a shared `spec-map.ts` resolver. Bottom strip, colour per status (green/red/orange). Applies in both compare (drift gate) and changelog (git-history diff), and post-discord now attaches the captioned removed images. Also raise the per-engine drift floor 0.2% → 0.5% (changedRatio) to absorb the sub-1% inter-run flakiness seen after the re-baseline, while still catching real localized changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4330c9344e
commit
9787efc2c9
9 changed files with 360 additions and 101 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"_note": "engine tags are best-effort (gen-manifest.ts); the name list is authoritative. Refine engines after calibration.",
|
"_note": "engine tags are best-effort (spec-map.ts); the name list is authoritative. Refine engines after calibration.",
|
||||||
"screenshots": [
|
"screenshots": [
|
||||||
{
|
{
|
||||||
"name": "01-loading.png",
|
"name": "01-loading.png",
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,11 @@ import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { execFileSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
import { BASELINE_DIRS, DIFF_OUT_DIR, floorFor } from './config';
|
import { PNG } from 'pngjs';
|
||||||
|
import { BASELINE_DIRS, DIFF_OUT_DIR, floorFor, labelText, LABEL } from './config';
|
||||||
import { comparePair, type Report } from './compare';
|
import { comparePair, type Report } from './compare';
|
||||||
import { loadPng, savePng } from './image-ops';
|
import { loadPng, savePng, withBottomLabel } from './image-ops';
|
||||||
|
import { buildSpecResolver } from './spec-map';
|
||||||
import { buildAttachments, paginate, postMessage } from './post-discord';
|
import { buildAttachments, paginate, postMessage } from './post-discord';
|
||||||
|
|
||||||
function git(root: string, args: string[]): string {
|
function git(root: string, args: string[]): string {
|
||||||
|
|
@ -77,6 +79,23 @@ async function main(): Promise<void> {
|
||||||
unchangedCount: 0,
|
unchangedCount: 0,
|
||||||
driftLikely: false,
|
driftLikely: false,
|
||||||
};
|
};
|
||||||
|
const { specFor } = buildSpecResolver(cwd);
|
||||||
|
|
||||||
|
// Load a git blob (a committed PNG at `rev`) as a decoded PNG, or null if absent.
|
||||||
|
const blobPng = (rev: string, p: string): PNG | null => {
|
||||||
|
const b = gitBlob(repoRoot, rev, p);
|
||||||
|
if (!b) return null;
|
||||||
|
const f = path.join(tmp, `${rev.slice(0, 7)}-${path.basename(p)}`);
|
||||||
|
fs.writeFileSync(f, b);
|
||||||
|
return loadPng(f);
|
||||||
|
};
|
||||||
|
// Save a single captioned image (added/removed) and record it in the report.
|
||||||
|
const saveSingle = (img: PNG, name: string, status: 'added' | 'removed'): void => {
|
||||||
|
const suffix = status === 'added' ? 'added' : 'removed';
|
||||||
|
const rel = path.join(DIFF_OUT_DIR, `${name}.${suffix}.png`);
|
||||||
|
savePng(path.join(cwd, rel), withBottomLabel(img, labelText(status, name, specFor(name)), LABEL.colors[status]));
|
||||||
|
report[status].push({ name, image: rel });
|
||||||
|
};
|
||||||
|
|
||||||
for (const line of diff.split('\n')) {
|
for (const line of diff.split('\n')) {
|
||||||
const parts = line.split('\t');
|
const parts = line.split('\t');
|
||||||
|
|
@ -86,28 +105,25 @@ async function main(): Promise<void> {
|
||||||
const name = path.basename(repoPath);
|
const name = path.basename(repoPath);
|
||||||
|
|
||||||
if (status === 'A' || status === 'R') {
|
if (status === 'A' || status === 'R') {
|
||||||
const buf = gitBlob(repoRoot, head, repoPath);
|
const img = blobPng(head, repoPath);
|
||||||
if (buf) {
|
if (img) saveSingle(img, name, 'added');
|
||||||
const rel = path.join(DIFF_OUT_DIR, `${name}.added.png`);
|
if (status === 'R') {
|
||||||
fs.writeFileSync(path.join(cwd, rel), buf);
|
const oldName = path.basename(oldPath);
|
||||||
report.added.push({ name, image: rel });
|
const oldImg = blobPng(base, oldPath);
|
||||||
|
if (oldImg) saveSingle(oldImg, oldName, 'removed');
|
||||||
}
|
}
|
||||||
if (status === 'R') report.removed.push({ name: path.basename(oldPath) });
|
|
||||||
} else if (status === 'D') {
|
} else if (status === 'D') {
|
||||||
report.removed.push({ name });
|
const img = blobPng(base, repoPath);
|
||||||
|
if (img) saveSingle(img, name, 'removed');
|
||||||
} else {
|
} else {
|
||||||
// Modified: triptych old vs new.
|
// Modified: captioned triptych old vs new.
|
||||||
const oldBuf = gitBlob(repoRoot, base, repoPath);
|
const oldImg = blobPng(base, repoPath);
|
||||||
const newBuf = gitBlob(repoRoot, head, repoPath);
|
const newImg = blobPng(head, repoPath);
|
||||||
if (!oldBuf || !newBuf) continue;
|
if (!oldImg || !newImg) continue;
|
||||||
const oldFile = path.join(tmp, `old-${name}`);
|
const { result, heatmap, triptych } = comparePair(oldImg, newImg, name, floorFor(name));
|
||||||
const newFile = path.join(tmp, `new-${name}`);
|
|
||||||
fs.writeFileSync(oldFile, oldBuf);
|
|
||||||
fs.writeFileSync(newFile, newBuf);
|
|
||||||
const { result, heatmap, triptych } = comparePair(loadPng(oldFile), loadPng(newFile), name, floorFor(name));
|
|
||||||
const triptychRel = path.join(DIFF_OUT_DIR, `${name}.triptych.png`);
|
const triptychRel = path.join(DIFF_OUT_DIR, `${name}.triptych.png`);
|
||||||
const heatmapRel = path.join(DIFF_OUT_DIR, `${name}.heatmap.png`);
|
const heatmapRel = path.join(DIFF_OUT_DIR, `${name}.heatmap.png`);
|
||||||
savePng(path.join(cwd, triptychRel), triptych);
|
savePng(path.join(cwd, triptychRel), withBottomLabel(triptych, labelText('changed', name, specFor(name)), LABEL.colors.changed));
|
||||||
savePng(path.join(cwd, heatmapRel), heatmap);
|
savePng(path.join(cwd, heatmapRel), heatmap);
|
||||||
report.changed.push({ ...result, triptych: triptychRel, heatmap: heatmapRel });
|
report.changed.push({ ...result, triptych: triptychRel, heatmap: heatmapRel });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,11 @@ import {
|
||||||
type Manifest,
|
type Manifest,
|
||||||
floorFor,
|
floorFor,
|
||||||
isIgnored,
|
isIgnored,
|
||||||
|
labelText,
|
||||||
|
LABEL,
|
||||||
} from './config';
|
} from './config';
|
||||||
import { diffImages, cluster, drawBoxes, composite, loadPng, savePng, type Box } from './image-ops';
|
import { diffImages, cluster, drawBoxes, composite, loadPng, savePng, withBottomLabel, type Box } from './image-ops';
|
||||||
|
import { buildSpecResolver } from './spec-map';
|
||||||
|
|
||||||
export type PairVerdict = 'unchanged' | 'changed';
|
export type PairVerdict = 'unchanged' | 'changed';
|
||||||
|
|
||||||
|
|
@ -79,7 +82,7 @@ export type Report = {
|
||||||
generatedFor: string | null;
|
generatedFor: string | null;
|
||||||
changed: ChangedEntry[];
|
changed: ChangedEntry[];
|
||||||
added: Array<{ name: string; image: string }>;
|
added: Array<{ name: string; image: string }>;
|
||||||
removed: Array<{ name: string }>;
|
removed: Array<{ name: string; image: string }>;
|
||||||
unchangedCount: number;
|
unchangedCount: number;
|
||||||
/** many changes, mostly drift-like ⇒ probably a host Mesa/font refresh; re-promote rather than debug */
|
/** many changes, mostly drift-like ⇒ probably a host Mesa/font refresh; re-promote rather than debug */
|
||||||
driftLikely: boolean;
|
driftLikely: boolean;
|
||||||
|
|
@ -130,6 +133,7 @@ export function classify(root: string, sha: string | null): Report {
|
||||||
const manifest = loadManifest(root);
|
const manifest = loadManifest(root);
|
||||||
const outDir = path.join(root, DIFF_OUT_DIR);
|
const outDir = path.join(root, DIFF_OUT_DIR);
|
||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const { specFor } = buildSpecResolver(root); // name → spec, for the caption strip
|
||||||
|
|
||||||
const report: Report = {
|
const report: Report = {
|
||||||
generatedFor: sha,
|
generatedFor: sha,
|
||||||
|
|
@ -148,7 +152,11 @@ export function classify(root: string, sha: string | null): Report {
|
||||||
// an intentional removal. (The stronger "did the spec actually run" cross-check
|
// an intentional removal. (The stronger "did the spec actually run" cross-check
|
||||||
// against the Playwright JSON report lands with the manifest work.)
|
// against the Playwright JSON report lands with the manifest work.)
|
||||||
if (manifest?.screenshots.some((e) => e.name === name)) {
|
if (manifest?.screenshots.some((e) => e.name === name)) {
|
||||||
report.removed.push({ name });
|
// Removed now gets a captioned image (the old baseline) so it's visible in Discord.
|
||||||
|
const imageRel = path.join(DIFF_OUT_DIR, `${name}.removed.png`);
|
||||||
|
const labeled = withBottomLabel(loadPng(baselinePath), labelText('removed', name, specFor(name)), LABEL.colors.removed);
|
||||||
|
savePng(path.join(root, imageRel), labeled);
|
||||||
|
report.removed.push({ name, image: imageRel });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +172,7 @@ export function classify(root: string, sha: string | null): Report {
|
||||||
}
|
}
|
||||||
const triptychRel = path.join(DIFF_OUT_DIR, `${name}.triptych.png`);
|
const triptychRel = path.join(DIFF_OUT_DIR, `${name}.triptych.png`);
|
||||||
const heatmapRel = path.join(DIFF_OUT_DIR, `${name}.heatmap.png`);
|
const heatmapRel = path.join(DIFF_OUT_DIR, `${name}.heatmap.png`);
|
||||||
savePng(path.join(root, triptychRel), triptych);
|
savePng(path.join(root, triptychRel), withBottomLabel(triptych, labelText('changed', name, specFor(name)), LABEL.colors.changed));
|
||||||
savePng(path.join(root, heatmapRel), heatmap);
|
savePng(path.join(root, heatmapRel), heatmap);
|
||||||
report.changed.push({ ...result, triptych: triptychRel, heatmap: heatmapRel });
|
report.changed.push({ ...result, triptych: triptychRel, heatmap: heatmapRel });
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +181,8 @@ export function classify(root: string, sha: string | null): Report {
|
||||||
for (const name of actuals) {
|
for (const name of actuals) {
|
||||||
if (baselines.has(name)) continue;
|
if (baselines.has(name)) continue;
|
||||||
const imageRel = path.join(DIFF_OUT_DIR, `${name}.added.png`);
|
const imageRel = path.join(DIFF_OUT_DIR, `${name}.added.png`);
|
||||||
fs.copyFileSync(path.join(resultsDir, name), path.join(root, imageRel));
|
const labeled = withBottomLabel(loadPng(path.join(resultsDir, name)), labelText('added', name, specFor(name)), LABEL.colors.added);
|
||||||
|
savePng(path.join(root, imageRel), labeled);
|
||||||
report.added.push({ name, image: imageRel });
|
report.added.push({ name, image: imageRel });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,16 +60,17 @@ export const TRIPTYCH = {
|
||||||
* for the drift-vs-regression heuristic (broad + low-intensity ⇒ environment
|
* for the drift-vs-regression heuristic (broad + low-intensity ⇒ environment
|
||||||
* drift, not a localized regression), not for the primary verdict.
|
* drift, not a localized regression), not for the primary verdict.
|
||||||
*
|
*
|
||||||
* NOTE: these are PLACEHOLDERS. `npm run screenshots:noise` renders the suite
|
* Set to 0.5% — the re-baseline run showed 355/356 images intra-CI-stable well
|
||||||
* twice on the CI host and prints the real intra-CI floor per engine; set
|
* under this, but a couple of runs since had sub-1% inter-run flakiness, so 0.5%
|
||||||
* `changedRatio ≈ measured × 3` from that and commit the numbers here.
|
* gives headroom while still catching real localized changes. (`npm run
|
||||||
|
* screenshots:noise` on two CI renders can refine per-engine numbers later.)
|
||||||
*/
|
*/
|
||||||
export type EngineFloor = { changedRatio: number; meanChannelGuard: number };
|
export type EngineFloor = { changedRatio: number; meanChannelGuard: number };
|
||||||
|
|
||||||
export const FLOORS: Record<string, EngineFloor> = {
|
export const FLOORS: Record<string, EngineFloor> = {
|
||||||
'firefox-llvmpipe': { changedRatio: 0.002, meanChannelGuard: 2.0 },
|
'firefox-llvmpipe': { changedRatio: 0.005, meanChannelGuard: 2.0 },
|
||||||
'chromium-swiftshader': { changedRatio: 0.002, meanChannelGuard: 2.0 },
|
'chromium-swiftshader': { changedRatio: 0.005, meanChannelGuard: 2.0 },
|
||||||
default: { changedRatio: 0.002, meanChannelGuard: 2.0 },
|
default: { changedRatio: 0.005, meanChannelGuard: 2.0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -92,6 +93,27 @@ export function isIgnored(name: string): boolean {
|
||||||
return IGNORE_SCREENSHOTS.has(name);
|
return IGNORE_SCREENSHOTS.has(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bottom caption strip baked onto each posted composite (status + name + spec). */
|
||||||
|
export const LABEL = {
|
||||||
|
maxScale: 3, // bitmap-font scale; auto-fit picks the largest that fits the width
|
||||||
|
vpad: 5,
|
||||||
|
hpad: 8,
|
||||||
|
text: [255, 255, 255] as [number, number, number], // white
|
||||||
|
colors: {
|
||||||
|
added: [46, 125, 50] as [number, number, number], // green
|
||||||
|
removed: [198, 40, 40] as [number, number, number], // red
|
||||||
|
changed: [239, 108, 0] as [number, number, number], // orange
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LabelStatus = 'added' | 'removed' | 'changed';
|
||||||
|
|
||||||
|
/** Caption text: `CHANGED name.png · kicad/pcbnew.spec.ts` (spec omitted if unknown). */
|
||||||
|
export function labelText(status: LabelStatus, name: string, spec: string | null): string {
|
||||||
|
const s = status.toUpperCase();
|
||||||
|
return spec ? `${s} ${name} · ${spec}` : `${s} ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
export type ManifestEntry = { name: string; engine: string };
|
export type ManifestEntry = { name: string; engine: string };
|
||||||
export type Manifest = { screenshots: ManifestEntry[] };
|
export type Manifest = { screenshots: ManifestEntry[] };
|
||||||
|
|
||||||
|
|
|
||||||
123
tests/tools/screenshots/font8x8.ts
Normal file
123
tests/tools/screenshots/font8x8.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
/**
|
||||||
|
* Minimal 8x8 bitmap font for baking captions onto screenshot composites.
|
||||||
|
*
|
||||||
|
* Glyphs are the public-domain `font8x8_basic` set (Daniel Hepper's font8x8,
|
||||||
|
* https://github.com/dhepper/font8x8 — public domain / "do whatever you want"),
|
||||||
|
* printable ASCII 0x20–0x7E, plus a custom middle-dot (·, 0xB7) used as the label
|
||||||
|
* separator. Public domain, so fine to embed under our GPLv3.
|
||||||
|
*
|
||||||
|
* Each glyph is 8 row-bytes (top→bottom). Within a row byte, bit `c` (LSB = 0) is
|
||||||
|
* column `c`, left→right: pixel (c,row) is set iff `(byte >> c) & 1`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
|
const GLYPHS: Record<string, number[]> = {
|
||||||
|
' ': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
'!': [0x18, 0x3c, 0x3c, 0x18, 0x18, 0x00, 0x18, 0x00],
|
||||||
|
'"': [0x36, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
'#': [0x36, 0x36, 0x7f, 0x36, 0x7f, 0x36, 0x36, 0x00],
|
||||||
|
'$': [0x0c, 0x3e, 0x03, 0x1e, 0x30, 0x1f, 0x0c, 0x00],
|
||||||
|
'%': [0x00, 0x63, 0x33, 0x18, 0x0c, 0x66, 0x63, 0x00],
|
||||||
|
'&': [0x1c, 0x36, 0x1c, 0x6e, 0x3b, 0x33, 0x6e, 0x00],
|
||||||
|
"'": [0x06, 0x06, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
'(': [0x18, 0x0c, 0x06, 0x06, 0x06, 0x0c, 0x18, 0x00],
|
||||||
|
')': [0x06, 0x0c, 0x18, 0x18, 0x18, 0x0c, 0x06, 0x00],
|
||||||
|
'*': [0x00, 0x66, 0x3c, 0xff, 0x3c, 0x66, 0x00, 0x00],
|
||||||
|
'+': [0x00, 0x0c, 0x0c, 0x3f, 0x0c, 0x0c, 0x00, 0x00],
|
||||||
|
',': [0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c, 0x06],
|
||||||
|
'-': [0x00, 0x00, 0x00, 0x3f, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
'.': [0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c, 0x00],
|
||||||
|
'/': [0x60, 0x30, 0x18, 0x0c, 0x06, 0x03, 0x01, 0x00],
|
||||||
|
'0': [0x3e, 0x63, 0x73, 0x7b, 0x6f, 0x67, 0x3e, 0x00],
|
||||||
|
'1': [0x0c, 0x0e, 0x0c, 0x0c, 0x0c, 0x0c, 0x3f, 0x00],
|
||||||
|
'2': [0x1e, 0x33, 0x30, 0x1c, 0x06, 0x33, 0x3f, 0x00],
|
||||||
|
'3': [0x1e, 0x33, 0x30, 0x1c, 0x30, 0x33, 0x1e, 0x00],
|
||||||
|
'4': [0x38, 0x3c, 0x36, 0x33, 0x7f, 0x30, 0x78, 0x00],
|
||||||
|
'5': [0x3f, 0x03, 0x1f, 0x30, 0x30, 0x33, 0x1e, 0x00],
|
||||||
|
'6': [0x1c, 0x06, 0x03, 0x1f, 0x33, 0x33, 0x1e, 0x00],
|
||||||
|
'7': [0x3f, 0x33, 0x30, 0x18, 0x0c, 0x0c, 0x0c, 0x00],
|
||||||
|
'8': [0x1e, 0x33, 0x33, 0x1e, 0x33, 0x33, 0x1e, 0x00],
|
||||||
|
'9': [0x1e, 0x33, 0x33, 0x3e, 0x30, 0x18, 0x0e, 0x00],
|
||||||
|
':': [0x00, 0x0c, 0x0c, 0x00, 0x00, 0x0c, 0x0c, 0x00],
|
||||||
|
';': [0x00, 0x0c, 0x0c, 0x00, 0x00, 0x0c, 0x0c, 0x06],
|
||||||
|
'<': [0x18, 0x0c, 0x06, 0x03, 0x06, 0x0c, 0x18, 0x00],
|
||||||
|
'=': [0x00, 0x00, 0x3f, 0x00, 0x00, 0x3f, 0x00, 0x00],
|
||||||
|
'>': [0x06, 0x0c, 0x18, 0x30, 0x18, 0x0c, 0x06, 0x00],
|
||||||
|
'?': [0x1e, 0x33, 0x30, 0x18, 0x0c, 0x00, 0x0c, 0x00],
|
||||||
|
'@': [0x3e, 0x63, 0x7b, 0x7b, 0x7b, 0x03, 0x1e, 0x00],
|
||||||
|
'A': [0x0c, 0x1e, 0x33, 0x33, 0x3f, 0x33, 0x33, 0x00],
|
||||||
|
'B': [0x3f, 0x66, 0x66, 0x3e, 0x66, 0x66, 0x3f, 0x00],
|
||||||
|
'C': [0x3c, 0x66, 0x03, 0x03, 0x03, 0x66, 0x3c, 0x00],
|
||||||
|
'D': [0x1f, 0x36, 0x66, 0x66, 0x66, 0x36, 0x1f, 0x00],
|
||||||
|
'E': [0x7f, 0x46, 0x16, 0x1e, 0x16, 0x46, 0x7f, 0x00],
|
||||||
|
'F': [0x7f, 0x46, 0x16, 0x1e, 0x16, 0x06, 0x0f, 0x00],
|
||||||
|
'G': [0x3c, 0x66, 0x03, 0x03, 0x73, 0x66, 0x7c, 0x00],
|
||||||
|
'H': [0x33, 0x33, 0x33, 0x3f, 0x33, 0x33, 0x33, 0x00],
|
||||||
|
'I': [0x1e, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x1e, 0x00],
|
||||||
|
'J': [0x78, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1e, 0x00],
|
||||||
|
'K': [0x67, 0x66, 0x36, 0x1e, 0x36, 0x66, 0x67, 0x00],
|
||||||
|
'L': [0x0f, 0x06, 0x06, 0x06, 0x46, 0x66, 0x7f, 0x00],
|
||||||
|
'M': [0x63, 0x77, 0x7f, 0x7f, 0x6b, 0x63, 0x63, 0x00],
|
||||||
|
'N': [0x63, 0x67, 0x6f, 0x7b, 0x73, 0x63, 0x63, 0x00],
|
||||||
|
'O': [0x1c, 0x36, 0x63, 0x63, 0x63, 0x36, 0x1c, 0x00],
|
||||||
|
'P': [0x3f, 0x66, 0x66, 0x3e, 0x06, 0x06, 0x0f, 0x00],
|
||||||
|
'Q': [0x1e, 0x33, 0x33, 0x33, 0x3b, 0x1e, 0x38, 0x00],
|
||||||
|
'R': [0x3f, 0x66, 0x66, 0x3e, 0x36, 0x66, 0x67, 0x00],
|
||||||
|
'S': [0x1e, 0x33, 0x07, 0x0e, 0x38, 0x33, 0x1e, 0x00],
|
||||||
|
'T': [0x3f, 0x2d, 0x0c, 0x0c, 0x0c, 0x0c, 0x1e, 0x00],
|
||||||
|
'U': [0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3f, 0x00],
|
||||||
|
'V': [0x33, 0x33, 0x33, 0x33, 0x33, 0x1e, 0x0c, 0x00],
|
||||||
|
'W': [0x63, 0x63, 0x63, 0x6b, 0x7f, 0x77, 0x63, 0x00],
|
||||||
|
'X': [0x63, 0x63, 0x36, 0x1c, 0x1c, 0x36, 0x63, 0x00],
|
||||||
|
'Y': [0x33, 0x33, 0x33, 0x1e, 0x0c, 0x0c, 0x1e, 0x00],
|
||||||
|
'Z': [0x7f, 0x63, 0x31, 0x18, 0x4c, 0x66, 0x7f, 0x00],
|
||||||
|
'[': [0x1e, 0x06, 0x06, 0x06, 0x06, 0x06, 0x1e, 0x00],
|
||||||
|
'\\': [0x03, 0x06, 0x0c, 0x18, 0x30, 0x60, 0x40, 0x00],
|
||||||
|
']': [0x1e, 0x18, 0x18, 0x18, 0x18, 0x18, 0x1e, 0x00],
|
||||||
|
'^': [0x08, 0x1c, 0x36, 0x63, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
'_': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff],
|
||||||
|
'`': [0x0c, 0x0c, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
'a': [0x00, 0x00, 0x1e, 0x30, 0x3e, 0x33, 0x6e, 0x00],
|
||||||
|
'b': [0x07, 0x06, 0x06, 0x3e, 0x66, 0x66, 0x3b, 0x00],
|
||||||
|
'c': [0x00, 0x00, 0x1e, 0x33, 0x03, 0x33, 0x1e, 0x00],
|
||||||
|
'd': [0x38, 0x30, 0x30, 0x3e, 0x33, 0x33, 0x6e, 0x00],
|
||||||
|
'e': [0x00, 0x00, 0x1e, 0x33, 0x3f, 0x03, 0x1e, 0x00],
|
||||||
|
'f': [0x1c, 0x36, 0x06, 0x0f, 0x06, 0x06, 0x0f, 0x00],
|
||||||
|
'g': [0x00, 0x00, 0x6e, 0x33, 0x33, 0x3e, 0x30, 0x1f],
|
||||||
|
'h': [0x07, 0x06, 0x36, 0x6e, 0x66, 0x66, 0x67, 0x00],
|
||||||
|
'i': [0x0c, 0x00, 0x0e, 0x0c, 0x0c, 0x0c, 0x1e, 0x00],
|
||||||
|
'j': [0x30, 0x00, 0x30, 0x30, 0x30, 0x33, 0x33, 0x1e],
|
||||||
|
'k': [0x07, 0x06, 0x66, 0x36, 0x1e, 0x36, 0x67, 0x00],
|
||||||
|
'l': [0x0e, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x1e, 0x00],
|
||||||
|
'm': [0x00, 0x00, 0x33, 0x7f, 0x7f, 0x6b, 0x63, 0x00],
|
||||||
|
'n': [0x00, 0x00, 0x1f, 0x33, 0x33, 0x33, 0x33, 0x00],
|
||||||
|
'o': [0x00, 0x00, 0x1e, 0x33, 0x33, 0x33, 0x1e, 0x00],
|
||||||
|
'p': [0x00, 0x00, 0x3b, 0x66, 0x66, 0x3e, 0x06, 0x0f],
|
||||||
|
'q': [0x00, 0x00, 0x6e, 0x33, 0x33, 0x3e, 0x30, 0x78],
|
||||||
|
'r': [0x00, 0x00, 0x3b, 0x6e, 0x66, 0x06, 0x0f, 0x00],
|
||||||
|
's': [0x00, 0x00, 0x3e, 0x03, 0x1e, 0x30, 0x1f, 0x00],
|
||||||
|
't': [0x08, 0x0c, 0x3e, 0x0c, 0x0c, 0x2c, 0x18, 0x00],
|
||||||
|
'u': [0x00, 0x00, 0x33, 0x33, 0x33, 0x33, 0x6e, 0x00],
|
||||||
|
'v': [0x00, 0x00, 0x33, 0x33, 0x33, 0x1e, 0x0c, 0x00],
|
||||||
|
'w': [0x00, 0x00, 0x63, 0x6b, 0x7f, 0x7f, 0x36, 0x00],
|
||||||
|
'x': [0x00, 0x00, 0x63, 0x36, 0x1c, 0x36, 0x63, 0x00],
|
||||||
|
'y': [0x00, 0x00, 0x33, 0x33, 0x33, 0x3e, 0x30, 0x1f],
|
||||||
|
'z': [0x00, 0x00, 0x3f, 0x19, 0x0c, 0x26, 0x3f, 0x00],
|
||||||
|
'{': [0x38, 0x0c, 0x0c, 0x07, 0x0c, 0x0c, 0x38, 0x00],
|
||||||
|
'|': [0x18, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00],
|
||||||
|
'}': [0x07, 0x0c, 0x0c, 0x38, 0x0c, 0x0c, 0x07, 0x00],
|
||||||
|
'~': [0x6e, 0x3b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||||
|
'·': [0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00], // custom middle-dot separator
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLANK = GLYPHS[' '];
|
||||||
|
|
||||||
|
/** 8 row-bytes for a character; falls back to a blank cell for anything unmapped. */
|
||||||
|
export function glyphFor(ch: string): number[] {
|
||||||
|
return GLYPHS[ch] ?? BLANK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a character has a real glyph (used for width estimation / fitting). */
|
||||||
|
export function hasGlyph(ch: string): boolean {
|
||||||
|
return ch in GLYPHS;
|
||||||
|
}
|
||||||
|
|
@ -4,15 +4,8 @@
|
||||||
*
|
*
|
||||||
* The NAME list is authoritative (it's the committed baseline set) and is what
|
* The NAME list is authoritative (it's the committed baseline set) and is what
|
||||||
* lets compare/promote tell an intentional REMOVAL from a flaky/absent render.
|
* lets compare/promote tell an intentional REMOVAL from a flaky/absent render.
|
||||||
* The ENGINE tag is best-effort (attributed by scanning which spec writes each
|
* The ENGINE tag is best-effort (spec-map.ts attributes each name to the spec that
|
||||||
* `test-results/<prefix>` and which project runs that spec) and only feeds the
|
* writes it, and that spec's project/engine) and only feeds the per-engine floors.
|
||||||
* per-engine floors — refine after calibration.
|
|
||||||
*
|
|
||||||
* Engine routing (from the two playwright configs):
|
|
||||||
* e2e/*.spec.ts → chromium-swiftshader (npm run test — wx suite)
|
|
||||||
* kicad/*.spec.ts → firefox-llvmpipe, or chromium-swiftshader if the
|
|
||||||
* spec is in PCBNEW_FAMILY_SPECS (chromium-ci on CI)
|
|
||||||
* web/*.spec.ts → firefox-llvmpipe (web config, --project=firefox)
|
|
||||||
*
|
*
|
||||||
* CLI (from tests/): tsx tools/screenshots/gen-manifest.ts [--check]
|
* CLI (from tests/): tsx tools/screenshots/gen-manifest.ts [--check]
|
||||||
* --check exits 1 if the committed manifest is stale (for CI hygiene).
|
* --check exits 1 if the committed manifest is stale (for CI hygiene).
|
||||||
|
|
@ -20,58 +13,7 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { BASELINE_DIRS, MANIFEST_PATH, isIgnored, type Manifest } from './config';
|
import { BASELINE_DIRS, MANIFEST_PATH, isIgnored, type Manifest } from './config';
|
||||||
|
import { buildSpecResolver } from './spec-map';
|
||||||
const CHROMIUM = 'chromium-swiftshader';
|
|
||||||
const FIREFOX = 'firefox-llvmpipe';
|
|
||||||
const DEFAULT_ENGINE = CHROMIUM; // baseline-screenshots is dominated by the wx suite
|
|
||||||
|
|
||||||
function listSpecs(dir: string): string[] {
|
|
||||||
const out: string[] = [];
|
|
||||||
if (!fs.existsSync(dir)) return out;
|
|
||||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
const p = path.join(dir, e.name);
|
|
||||||
if (e.isDirectory()) out.push(...listSpecs(p));
|
|
||||||
// Only spec files — a screenshot literal in a util (e.g. completeWizard's
|
|
||||||
// wizard-*) would be attributed to the util's dir, not its real caller.
|
|
||||||
// Leaving those unmatched lets them fall to the correct chromium default.
|
|
||||||
else if (e.name.endsWith('.spec.ts')) out.push(p);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** pcbnew-family spec basenames (routed to chromium-ci on CI), read from the config. */
|
|
||||||
function pcbnewFamily(root: string): Set<string> {
|
|
||||||
const cfg = fs.readFileSync(path.join(root, 'playwright-kicad.config.ts'), 'utf8');
|
|
||||||
const block = cfg.match(/PCBNEW_FAMILY_SPECS\s*=\s*\[([\s\S]*?)\]/)?.[1] ?? '';
|
|
||||||
return new Set([...block.matchAll(/'[^']*?([\w.-]+\.spec\.ts)'/g)].map((m) => m[1]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function engineForSpec(root: string, specPath: string, family: Set<string>): string {
|
|
||||||
const rel = path.relative(root, specPath);
|
|
||||||
const base = path.basename(specPath);
|
|
||||||
if (rel.startsWith('e2e/')) return CHROMIUM;
|
|
||||||
if (rel.startsWith('web/')) return FIREFOX;
|
|
||||||
if (rel.startsWith('kicad/')) return family.has(base) ? CHROMIUM : FIREFOX;
|
|
||||||
return DEFAULT_ENGINE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build prefix → engine from every `test-results/<prefix>` literal in the specs. */
|
|
||||||
function prefixEngineMap(root: string, family: Set<string>): Array<{ prefix: string; engine: string }> {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
for (const dir of ['e2e', 'kicad', 'web']) {
|
|
||||||
for (const spec of listSpecs(path.join(root, dir))) {
|
|
||||||
const engine = engineForSpec(root, spec, family);
|
|
||||||
const content = fs.readFileSync(spec, 'utf8');
|
|
||||||
for (const m of content.matchAll(/test-results\/([A-Za-z0-9_-]+)/g)) {
|
|
||||||
const prefix = m[1];
|
|
||||||
// First writer wins; a chromium spec shouldn't be overridden by a later firefox one for the same literal.
|
|
||||||
if (!map.has(prefix)) map.set(prefix, engine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Longest prefix first so the most specific match wins.
|
|
||||||
return [...map.entries()].map(([prefix, engine]) => ({ prefix, engine })).sort((a, b) => b.prefix.length - a.prefix.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function listBaselines(root: string): string[] {
|
function listBaselines(root: string): string[] {
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
|
|
@ -86,19 +28,16 @@ function listBaselines(root: string): string[] {
|
||||||
function main(): void {
|
function main(): void {
|
||||||
const check = process.argv.includes('--check');
|
const check = process.argv.includes('--check');
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const family = pcbnewFamily(root);
|
const resolver = buildSpecResolver(root);
|
||||||
const prefixes = prefixEngineMap(root, family);
|
|
||||||
|
|
||||||
let unmatched = 0;
|
let unmatched = 0;
|
||||||
const screenshots = listBaselines(root).map((name) => {
|
const screenshots = listBaselines(root).map((name) => {
|
||||||
const stem = name.replace(/\.png$/i, '');
|
if (resolver.specFor(name) === null) unmatched++;
|
||||||
const hit = prefixes.find((p) => stem === p.prefix || stem.startsWith(p.prefix));
|
return { name, engine: resolver.engineFor(name) };
|
||||||
if (!hit) unmatched++;
|
|
||||||
return { name, engine: hit?.engine ?? DEFAULT_ENGINE };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const manifest: Manifest & { _note: string } = {
|
const manifest: Manifest & { _note: string } = {
|
||||||
_note: 'engine tags are best-effort (gen-manifest.ts); the name list is authoritative. Refine engines after calibration.',
|
_note: 'engine tags are best-effort (spec-map.ts); the name list is authoritative. Refine engines after calibration.',
|
||||||
screenshots,
|
screenshots,
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(manifest, null, 2) + '\n';
|
const json = JSON.stringify(manifest, null, 2) + '\n';
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { PNG } from 'pngjs';
|
import { PNG } from 'pngjs';
|
||||||
import pixelmatch from 'pixelmatch';
|
import pixelmatch from 'pixelmatch';
|
||||||
import { PIXELMATCH, DIFF_COLOR, CLUSTER, TRIPTYCH } from './config';
|
import { PIXELMATCH, DIFF_COLOR, CLUSTER, TRIPTYCH, LABEL } from './config';
|
||||||
|
import { glyphFor } from './font8x8';
|
||||||
|
|
||||||
export type Box = { x: number; y: number; width: number; height: number; area: number };
|
export type Box = { x: number; y: number; width: number; height: number; area: number };
|
||||||
|
|
||||||
|
|
@ -254,3 +255,62 @@ export function composite(panels: PNG[]): PNG {
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Draw `text` at (x0,y0) with the 8x8 bitmap font, each pixel scaled `scale`×. Clips at edges. */
|
||||||
|
export function drawText(png: PNG, x0: number, y0: number, text: string, color: [number, number, number], scale: number): void {
|
||||||
|
let x = x0;
|
||||||
|
for (const ch of text) {
|
||||||
|
const glyph = glyphFor(ch);
|
||||||
|
for (let row = 0; row < 8; row++) {
|
||||||
|
const bits = glyph[row];
|
||||||
|
if (!bits) continue;
|
||||||
|
for (let col = 0; col < 8; col++) {
|
||||||
|
if (!((bits >> col) & 1)) continue;
|
||||||
|
for (let dy = 0; dy < scale; dy++) {
|
||||||
|
const py = y0 + row * scale + dy;
|
||||||
|
if (py < 0 || py >= png.height) continue;
|
||||||
|
for (let dx = 0; dx < scale; dx++) {
|
||||||
|
const px = x + col * scale + dx;
|
||||||
|
if (px < 0 || px >= png.width) continue;
|
||||||
|
const o = (py * png.width + px) * 4;
|
||||||
|
png.data[o] = color[0];
|
||||||
|
png.data[o + 1] = color[1];
|
||||||
|
png.data[o + 2] = color[2];
|
||||||
|
png.data[o + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x += 9 * scale; // 8px glyph + 1px spacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a copy of `png` with a `bg`-coloured caption strip appended at the bottom,
|
||||||
|
* showing `text` in white. Auto-fits the font scale to the width; if even scale 1
|
||||||
|
* overflows, truncates the tail (keeps the name, trims the spec) with `..`.
|
||||||
|
*/
|
||||||
|
export function withBottomLabel(png: PNG, text: string, bg: [number, number, number]): PNG {
|
||||||
|
const advance = 9; // per-char glyph cells at scale 1
|
||||||
|
const maxW = Math.max(1, png.width - 2 * LABEL.hpad);
|
||||||
|
let scale = LABEL.maxScale;
|
||||||
|
while (scale > 1 && text.length * advance * scale > maxW) scale--;
|
||||||
|
let label = text;
|
||||||
|
const maxChars = Math.max(1, Math.floor(maxW / (advance * scale)));
|
||||||
|
if (label.length > maxChars) label = label.slice(0, Math.max(1, maxChars - 2)) + '..';
|
||||||
|
|
||||||
|
const stripH = 8 * scale + 2 * LABEL.vpad;
|
||||||
|
const out = new PNG({ width: png.width, height: png.height + stripH });
|
||||||
|
png.data.copy(out.data, 0, 0, png.data.length); // original image on top (same width)
|
||||||
|
for (let y = png.height; y < out.height; y++) {
|
||||||
|
for (let x = 0; x < out.width; x++) {
|
||||||
|
const o = (y * out.width + x) * 4;
|
||||||
|
out.data[o] = bg[0];
|
||||||
|
out.data[o + 1] = bg[1];
|
||||||
|
out.data[o + 2] = bg[2];
|
||||||
|
out.data[o + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drawText(out, LABEL.hpad, png.height + LABEL.vpad, label, LABEL.text, scale);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,17 @@ export function buildAttachments(root: string, report: Report | null): { files:
|
||||||
const a = attach(root, ad.image, `ADDED_${ad.name}`);
|
const a = attach(root, ad.image, `ADDED_${ad.name}`);
|
||||||
if (a) files.push(a);
|
if (a) files.push(a);
|
||||||
}
|
}
|
||||||
|
const removedToShow = report.removed.length > FLOOD_N ? report.removed.slice(0, 3) : report.removed;
|
||||||
|
if (report.removed.length > FLOOD_N) {
|
||||||
|
notes.push(`➖ ${report.removed.length} removed (showing ${removedToShow.length})`);
|
||||||
|
}
|
||||||
|
for (const rm of removedToShow) {
|
||||||
|
if (files.length >= MAX_TOTAL_FILES) break;
|
||||||
|
const a = attach(root, rm.image, `REMOVED_${rm.name}`);
|
||||||
|
if (a) files.push(a);
|
||||||
|
}
|
||||||
const shown = files.length;
|
const shown = files.length;
|
||||||
const wanted = report.changed.length + addedToShow.length;
|
const wanted = report.changed.length + addedToShow.length + removedToShow.length;
|
||||||
if (wanted > shown) notes.push(`(${wanted - shown} more images omitted — see the CI artifact)`);
|
if (wanted > shown) notes.push(`(${wanted - shown} more images omitted — see the CI artifact)`);
|
||||||
return { files, notes };
|
return { files, notes };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
tests/tools/screenshots/spec-map.ts
Normal file
81
tests/tools/screenshots/spec-map.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* Resolve a screenshot name → the spec that writes it (and the engine that renders
|
||||||
|
* it), by scanning the specs for `page.screenshot({ path: 'test-results/<prefix>…' })`
|
||||||
|
* literals. Shared by gen-manifest.ts (engine tags) and compare/changelog (caption
|
||||||
|
* labels).
|
||||||
|
*
|
||||||
|
* Attribution is longest-prefix (`pcbnew-loaded` → the spec that writes `pcbnew-…`).
|
||||||
|
* Names written only by a helper (e.g. `wizard-*` via completeWizard, which lives in
|
||||||
|
* a util not a *.spec.ts) don't match → specFor returns null (the caption then just
|
||||||
|
* shows the name).
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export const CHROMIUM = 'chromium-swiftshader';
|
||||||
|
export const FIREFOX = 'firefox-llvmpipe';
|
||||||
|
export const DEFAULT_ENGINE = CHROMIUM; // baseline-screenshots is dominated by the wx suite
|
||||||
|
|
||||||
|
function listSpecs(dir: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (!fs.existsSync(dir)) return out;
|
||||||
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const p = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) out.push(...listSpecs(p));
|
||||||
|
// Only *.spec.ts — a literal in a util would be mis-attributed to the util's dir.
|
||||||
|
else if (e.name.endsWith('.spec.ts')) out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pcbnew-family spec basenames (routed to chromium-ci on CI), read from the config. */
|
||||||
|
function pcbnewFamily(root: string): Set<string> {
|
||||||
|
const cfg = fs.readFileSync(path.join(root, 'playwright-kicad.config.ts'), 'utf8');
|
||||||
|
const block = cfg.match(/PCBNEW_FAMILY_SPECS\s*=\s*\[([\s\S]*?)\]/)?.[1] ?? '';
|
||||||
|
return new Set([...block.matchAll(/'[^']*?([\w.-]+\.spec\.ts)'/g)].map((m) => m[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function engineForSpec(root: string, specPath: string, family: Set<string>): string {
|
||||||
|
const rel = path.relative(root, specPath);
|
||||||
|
const base = path.basename(specPath);
|
||||||
|
if (rel.startsWith('e2e/')) return CHROMIUM;
|
||||||
|
if (rel.startsWith('web/')) return FIREFOX;
|
||||||
|
if (rel.startsWith('kicad/')) return family.has(base) ? CHROMIUM : FIREFOX;
|
||||||
|
return DEFAULT_ENGINE;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrefixEntry = { prefix: string; engine: string; spec: string };
|
||||||
|
|
||||||
|
/** prefix → {engine, spec-rel-to-tests}, longest-prefix first. */
|
||||||
|
function prefixMap(root: string, family: Set<string>): PrefixEntry[] {
|
||||||
|
const map = new Map<string, { engine: string; spec: string }>();
|
||||||
|
for (const dir of ['e2e', 'kicad', 'web']) {
|
||||||
|
for (const spec of listSpecs(path.join(root, dir))) {
|
||||||
|
const engine = engineForSpec(root, spec, family);
|
||||||
|
const rel = path.relative(root, spec); // e.g. 'kicad/pcbnew.spec.ts'
|
||||||
|
const content = fs.readFileSync(spec, 'utf8');
|
||||||
|
for (const m of content.matchAll(/test-results\/([A-Za-z0-9_-]+)/g)) {
|
||||||
|
const prefix = m[1];
|
||||||
|
if (!map.has(prefix)) map.set(prefix, { engine, spec: rel }); // first writer wins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.map(([prefix, v]) => ({ prefix, ...v }))
|
||||||
|
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpecResolver = { specFor(name: string): string | null; engineFor(name: string): string };
|
||||||
|
|
||||||
|
/** Build a name→spec/engine resolver by scanning the specs under `root` once. */
|
||||||
|
export function buildSpecResolver(root: string): SpecResolver {
|
||||||
|
const prefixes = prefixMap(root, pcbnewFamily(root));
|
||||||
|
const match = (name: string): PrefixEntry | undefined => {
|
||||||
|
const stem = name.replace(/\.png$/i, '');
|
||||||
|
return prefixes.find((p) => stem === p.prefix || stem.startsWith(p.prefix));
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
specFor: (name) => match(name)?.spec ?? null,
|
||||||
|
engineFor: (name) => match(name)?.engine ?? DEFAULT_ENGINE,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue