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:
Viktor Vaczi 2026-07-02 12:07:41 +02:00
commit 9787efc2c9
9 changed files with 360 additions and 101 deletions

View file

@ -9,7 +9,8 @@
import * as fs from 'fs';
import { PNG } from 'pngjs';
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 };
@ -254,3 +255,62 @@ export function composite(panels: PNG[]): PNG {
}
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;
}