Skip to content

Commit 6afbf81

Browse files
committed
visual test
1 parent 417ca8e commit 6afbf81

8 files changed

Lines changed: 454 additions & 5 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ node_modules/
66
coverage/
77
.vitest-attachments/
88
src/test/browser/__screenshots__/
9+
test/visual/output/
910
test/integrations/*/dist/
1011
temp/
1112
.DS_Store
1213
*.mp4
1314
dogfood-output/
1415
.pnpm-store/
1516
.wt/
17+
test/visual/**/*.png
1618

17-
dist/
19+
dist/

eslint.config.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export default tseslint.config(
4141
"@typescript-eslint/no-unused-vars": "off",
4242
},
4343
},
44+
{
45+
files: ["test/visual/**/*.ts"],
46+
languageOptions: {
47+
globals: { ...globals.node },
48+
},
49+
},
4450
{
4551
files: ["scripts/check-site.mjs"],
4652
languageOptions: {

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
"test:watch": "vitest",
3939
"test:coverage": "vitest run --coverage",
4040
"test:browser": "node scripts/browser-smoke.mjs",
41+
"test:visual": "vitest run --project visual",
42+
"test:visual:update": "UPDATE_VISUAL=1 vitest run --project visual",
4143
"test:docs": "node scripts/docs-smoke.mjs",
4244
"test:site": "node scripts/check-site.mjs",
4345
"test:integrations": "node scripts/check-integrations.mjs",
@@ -52,10 +54,10 @@
5254
"check:docs": "node scripts/generate-docs.mjs --check && node scripts/generate-guides.mjs --check && node scripts/generate-markdown-docs.mjs --check && pnpm test:docs",
5355
"check:package": "node scripts/check-package.mjs",
5456
"check:publishing": "publint && attw --pack .",
55-
"lint": "eslint bench src scripts test/integrations *.config.*",
56-
"format": "prettier --write README.md 'plans/**/*.md' package.json tsconfig.json '*.config.*' 'bench/**/*.mjs' 'guide/js/guide.js' 'demo/edit/js/edit.js' 'demo/edit/src/*.js' 'scripts/**/*.mjs' 'src/**/*.ts' '.github/**/*.yml' '.prettierrc.json' '.vscode/*.json' 'test/integrations/**/*.{css,html,json,md,mjs,ts,tsx,vue}'",
57-
"format:check": "prettier --check README.md 'plans/**/*.md' package.json tsconfig.json '*.config.*' 'bench/**/*.mjs' 'guide/js/guide.js' 'demo/edit/js/edit.js' 'demo/edit/src/*.js' 'scripts/**/*.mjs' 'src/**/*.ts' '.github/**/*.yml' '.prettierrc.json' '.vscode/*.json' 'test/integrations/**/*.{css,html,json,md,mjs,ts,tsx,vue}'",
58-
"format:watch": "chokidar README.md 'plans/**/*.md' package.json tsconfig.json '*.config.*' 'bench/**/*.mjs' 'guide/js/guide.js' 'demo/edit/js/edit.js' 'demo/edit/src/*.js' 'scripts/**/*.mjs' 'src/**/*.ts' '.github/**/*.yml' '.prettierrc.json' '.vscode/*.json' 'test/integrations/**/*.{css,html,json,md,mjs,ts,tsx,vue}' -c \"prettier --write --ignore-unknown \\\"{path}\\\"\"",
57+
"lint": "eslint bench src scripts test/integrations test/visual *.config.*",
58+
"format": "prettier --write README.md 'plans/**/*.md' package.json tsconfig.json '*.config.*' 'bench/**/*.mjs' 'guide/js/guide.js' 'demo/edit/js/edit.js' 'demo/edit/src/*.js' 'scripts/**/*.mjs' 'src/**/*.ts' '.github/**/*.yml' '.prettierrc.json' '.vscode/*.json' 'test/integrations/**/*.{css,html,json,md,mjs,ts,tsx,vue}' 'test/visual/**/*.{ts,md}'",
59+
"format:check": "prettier --check README.md 'plans/**/*.md' package.json tsconfig.json '*.config.*' 'bench/**/*.mjs' 'guide/js/guide.js' 'demo/edit/js/edit.js' 'demo/edit/src/*.js' 'scripts/**/*.mjs' 'src/**/*.ts' '.github/**/*.yml' '.prettierrc.json' '.vscode/*.json' 'test/integrations/**/*.{css,html,json,md,mjs,ts,tsx,vue}' 'test/visual/**/*.{ts,md}'",
60+
"format:watch": "chokidar README.md 'plans/**/*.md' package.json tsconfig.json '*.config.*' 'bench/**/*.mjs' 'guide/js/guide.js' 'demo/edit/js/edit.js' 'demo/edit/src/*.js' 'scripts/**/*.mjs' 'src/**/*.ts' '.github/**/*.yml' '.prettierrc.json' '.vscode/*.json' 'test/integrations/**/*.{css,html,json,md,mjs,ts,tsx,vue}' 'test/visual/**/*.{ts,md}' -c \"prettier --write --ignore-unknown \\\"{path}\\\"\"",
5961
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm check:docs && pnpm build && pnpm check:artifacts && pnpm test:browser && pnpm test:site && pnpm check:package && pnpm test:integrations && pnpm check:publishing",
6062
"prepack": "pnpm build",
6163
"docs": "node scripts/generate-docs.mjs && node scripts/generate-guides.mjs && node scripts/generate-markdown-docs.mjs",

test/visual/Color.visual.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, it } from "vitest";
2+
import { Color } from "../../src/Color";
3+
import { ColorType } from "../../src/Types";
4+
import { matchBaseline, renderImage } from "./_visual";
5+
6+
const SIZE = 300;
7+
8+
/** Slices taken along the third axis, as a fraction of that axis' range. */
9+
const SLICES = [0.1, 0.35, 0.6, 0.85];
10+
11+
/**
12+
* Which two axes of a colour space are plotted (x to the right, y upwards) and
13+
* which one is sliced. Spaces with a lightness axis always slice on lightness;
14+
* XYZ slices on Y, its luminance axis.
15+
*/
16+
const PLOTS: {
17+
mode: ColorType;
18+
x: number;
19+
y: number;
20+
z: number;
21+
zLabel: string;
22+
}[] = [
23+
{ mode: "rgb", x: 0, y: 1, z: 2, zLabel: "b" },
24+
{ mode: "hsl", x: 0, y: 1, z: 2, zLabel: "l" },
25+
{ mode: "hsb", x: 0, y: 1, z: 2, zLabel: "b" },
26+
{ mode: "lab", x: 1, y: 2, z: 0, zLabel: "l" },
27+
{ mode: "lch", x: 2, y: 1, z: 0, zLabel: "l" },
28+
{ mode: "luv", x: 1, y: 2, z: 0, zLabel: "l" },
29+
{ mode: "xyz", x: 0, y: 2, z: 1, zLabel: "y" },
30+
{ mode: "oklab", x: 1, y: 2, z: 0, zLabel: "l" },
31+
{ mode: "oklch", x: 2, y: 1, z: 0, zLabel: "l" },
32+
];
33+
34+
/**
35+
* Convert a colour given in normalized (0...1) coordinates of `mode` into
36+
* full-range sRGB, as a plain `[r, g, b]` triplet (Color's own 4th component is
37+
* alpha in 0...1, which is not what the image writer expects).
38+
*
39+
* Colours outside the sRGB gamut come back clipped per channel, exactly as Pts
40+
* returns them — the plots record what the library does, artefacts included.
41+
*/
42+
function toRGB(mode: ColorType, values: number[]): number[] {
43+
const c = Color.from(values[0], values[1], values[2], 1).toMode(mode);
44+
c.normalized = true;
45+
const rgb =
46+
mode === "rgb"
47+
? c.$normalize(false)
48+
: Color[`${mode.toUpperCase()}toRGB`](c, true, false);
49+
return [rgb[0], rgb[1], rgb[2]];
50+
}
51+
52+
describe("Color space plots", () => {
53+
const cases = PLOTS.flatMap((plot) =>
54+
SLICES.map((slice) => ({ plot, slice })),
55+
);
56+
57+
it.each(cases)("$plot.mode slice $plot.zLabel=$slice", ({ plot, slice }) => {
58+
const values = [0, 0, 0];
59+
values[plot.z] = slice;
60+
61+
const image = renderImage(SIZE, SIZE, (px, py) => {
62+
values[plot.x] = (px + 0.5) / SIZE;
63+
values[plot.y] = 1 - (py + 0.5) / SIZE;
64+
return toRGB(plot.mode, values);
65+
});
66+
67+
const pct = Math.round(slice * 100);
68+
matchBaseline(`color-${plot.mode}-${plot.zLabel}${pct}`, image);
69+
});
70+
});

test/visual/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Visual regression baselines
2+
3+
PNG snapshots that pin down what Pts renders, so a code change that alters
4+
pixels has to be an explicit decision.
5+
6+
- `baselines/` — committed reference images. Treat a change here like a diff:
7+
open the before/after and confirm the new pixels are what you wanted.
8+
- `output/` — written only when a test fails (git-ignored): the rendered image
9+
plus a `.diff.png` mask with differing pixels in red over a dimmed baseline.
10+
11+
## Running
12+
13+
```sh
14+
pnpm test:visual # compare against baselines
15+
pnpm test:visual:update # re-record baselines after an intended change
16+
```
17+
18+
Visual specs live in `src/test/visual/` and run as part of `pnpm test` too.
19+
20+
A missing baseline is recorded automatically on a local run (with a warning) but
21+
fails when `CI` is set, so an unreviewed snapshot can never sneak through CI.
22+
23+
A comparison fails if **any** pixel differs by more than 1 per channel, or if
24+
more than 1% of pixels differ at all. The first catches real changes; the second
25+
catches a change that only nudges the whole image by a single level, while still
26+
tolerating scattered last-bit float noise between machines. Both are adjustable
27+
per call via `matchBaseline`'s options.
28+
29+
## What is covered
30+
31+
### Color spaces (`Color.visual.spec.ts`)
32+
33+
Each color space is plotted as a 300×300 image: two of its axes span the image
34+
(x to the right, y upwards, both across the space's full range) while the third
35+
is held at 10%, 35%, 60% and 85% of its range — one image per slice, named
36+
`color-<mode>-<axis><pct>.png`.
37+
38+
The sliced axis is lightness wherever the space has one (`hsl`, `hsb`, `lab`,
39+
`lch`, `luv`, `oklab`, `oklch`), Y for `xyz`, and blue for `rgb`.
40+
41+
Colors outside the sRGB gamut come back clipped per channel, exactly as Pts
42+
returns them: the plots record what the library does, artifacts included. That
43+
is why a low-lightness `lab`/`lch`/`luv`/`xyz` slice looks brighter and more
44+
colorful than its lightness implies — at LCH L=10 only a quarter of the plotted
45+
plane is displayable, and clipping Lab(10, 0, -100) yields `rgb(0,47,176)`.
46+
Those regions are flat, so the sensitive part of such a plot is the gamut
47+
boundary, which moves as soon as conversion math changes.

test/visual/_png.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Minimal PNG encoder/decoder for visual regression baselines.
3+
*
4+
* Only the subset Pts' visual tests need: 8-bit RGBA, non-interlaced, single
5+
* IDAT stream. Kept dependency-free so baselines can be written and compared
6+
* in the plain node test project.
7+
*/
8+
9+
import { deflateSync, inflateSync } from "node:zlib";
10+
11+
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
12+
const BYTES_PER_PIXEL = 4;
13+
14+
export type RGBAImage = {
15+
width: number;
16+
height: number;
17+
pixels: Uint8ClampedArray; // width * height * 4, row-major, top-down
18+
};
19+
20+
const CRC_TABLE = (() => {
21+
const table = new Int32Array(256);
22+
for (let n = 0; n < 256; n++) {
23+
let c = n;
24+
for (let k = 0; k < 8; k++) {
25+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
26+
}
27+
table[n] = c;
28+
}
29+
return table;
30+
})();
31+
32+
function crc32(buf: Buffer): number {
33+
let c = -1;
34+
for (let i = 0; i < buf.length; i++) {
35+
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
36+
}
37+
return (c ^ -1) >>> 0;
38+
}
39+
40+
function chunk(type: string, data: Buffer): Buffer {
41+
const head = Buffer.alloc(8);
42+
head.writeUInt32BE(data.length, 0);
43+
head.write(type, 4, "ascii");
44+
const crc = Buffer.alloc(4);
45+
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
46+
return Buffer.concat([head, data, crc]);
47+
}
48+
49+
/**
50+
* Encode an RGBA image as a PNG buffer. Rows use the Sub filter, which keeps
51+
* smooth horizontal gradients (ie, most colour plots) small.
52+
*/
53+
export function encodePNG(image: RGBAImage): Buffer {
54+
const { width, height, pixels } = image;
55+
const stride = width * BYTES_PER_PIXEL;
56+
const raw = Buffer.alloc(height * (stride + 1));
57+
58+
for (let y = 0; y < height; y++) {
59+
const src = y * stride;
60+
const dest = y * (stride + 1);
61+
raw[dest] = 1; // Sub filter
62+
for (let i = 0; i < stride; i++) {
63+
const left = i >= BYTES_PER_PIXEL ? pixels[src + i - BYTES_PER_PIXEL] : 0;
64+
raw[dest + 1 + i] = (pixels[src + i] - left) & 0xff;
65+
}
66+
}
67+
68+
const ihdr = Buffer.alloc(13);
69+
ihdr.writeUInt32BE(width, 0);
70+
ihdr.writeUInt32BE(height, 4);
71+
ihdr[8] = 8; // bit depth
72+
ihdr[9] = 6; // colour type: RGBA
73+
ihdr[10] = 0; // compression
74+
ihdr[11] = 0; // filter
75+
ihdr[12] = 0; // interlace
76+
77+
return Buffer.concat([
78+
SIGNATURE,
79+
chunk("IHDR", ihdr),
80+
chunk("IDAT", deflateSync(raw, { level: 9 })),
81+
chunk("IEND", Buffer.alloc(0)),
82+
]);
83+
}
84+
85+
function paeth(a: number, b: number, c: number): number {
86+
const p = a + b - c;
87+
const pa = Math.abs(p - a);
88+
const pb = Math.abs(p - b);
89+
const pc = Math.abs(p - c);
90+
if (pa <= pb && pa <= pc) return a;
91+
return pb <= pc ? b : c;
92+
}
93+
94+
/**
95+
* Decode a PNG buffer produced by [`encodePNG`](#link).
96+
*/
97+
export function decodePNG(buf: Buffer): RGBAImage {
98+
if (!buf.subarray(0, 8).equals(SIGNATURE)) {
99+
throw new Error("Not a PNG file");
100+
}
101+
102+
let width = 0;
103+
let height = 0;
104+
const idat: Buffer[] = [];
105+
106+
let offset = 8;
107+
while (offset < buf.length) {
108+
const length = buf.readUInt32BE(offset);
109+
const type = buf.toString("ascii", offset + 4, offset + 8);
110+
const data = buf.subarray(offset + 8, offset + 8 + length);
111+
offset += 12 + length;
112+
113+
if (type === "IHDR") {
114+
width = data.readUInt32BE(0);
115+
height = data.readUInt32BE(4);
116+
if (data[8] !== 8 || data[9] !== 6 || data[12] !== 0) {
117+
throw new Error("Only 8-bit non-interlaced RGBA PNGs are supported");
118+
}
119+
} else if (type === "IDAT") {
120+
idat.push(data);
121+
} else if (type === "IEND") {
122+
break;
123+
}
124+
}
125+
126+
const raw = inflateSync(Buffer.concat(idat));
127+
const stride = width * BYTES_PER_PIXEL;
128+
const pixels = new Uint8ClampedArray(height * stride);
129+
130+
for (let y = 0; y < height; y++) {
131+
const filter = raw[y * (stride + 1)];
132+
const src = y * (stride + 1) + 1;
133+
const dest = y * stride;
134+
for (let i = 0; i < stride; i++) {
135+
const a = i >= BYTES_PER_PIXEL ? pixels[dest + i - BYTES_PER_PIXEL] : 0;
136+
const b = y > 0 ? pixels[dest + i - stride] : 0;
137+
const c =
138+
y > 0 && i >= BYTES_PER_PIXEL
139+
? pixels[dest + i - stride - BYTES_PER_PIXEL]
140+
: 0;
141+
const x = raw[src + i];
142+
let value = x;
143+
if (filter === 1) value = x + a;
144+
else if (filter === 2) value = x + b;
145+
else if (filter === 3) value = x + ((a + b) >> 1);
146+
else if (filter === 4) value = x + paeth(a, b, c);
147+
else if (filter !== 0) throw new Error(`Unknown PNG filter ${filter}`);
148+
pixels[dest + i] = value & 0xff;
149+
}
150+
}
151+
152+
return { width, height, pixels };
153+
}

0 commit comments

Comments
 (0)