From 99cd11e5b96b6931e23964b24f2acea9b2e2c1e9 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 7 Aug 2026 15:08:03 -0400 Subject: [PATCH] test(web): sweep every generator for flat renders A generator that paints one colour throws nothing and draws a valid image; the only symptom is a swatch that looks like the Solid pattern. This renders each generator at both the sizes the editor uses, across seeds and the corners of the slider space, and fails on a single colour or on one colour holding more than 97% of the pixels. It is what found the Cyclic automaton and Weathered cases. Co-Authored-By: Claude Opus 5 --- web/scripts/camo-patterns.fixture.ts | 11 +++ web/scripts/camo-patterns.test.mjs | 112 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 web/scripts/camo-patterns.fixture.ts create mode 100644 web/scripts/camo-patterns.test.mjs diff --git a/web/scripts/camo-patterns.fixture.ts b/web/scripts/camo-patterns.fixture.ts new file mode 100644 index 0000000..604b847 --- /dev/null +++ b/web/scripts/camo-patterns.fixture.ts @@ -0,0 +1,11 @@ +/** + * What camo-patterns.test.mjs needs, in one module it can bundle. + * + * Same reason as camo-record.fixture.ts: the test runs on Node and the code + * under test is TypeScript, so it gets bundled with the site's own bundler, + * and a bundler resolves bare imports relative to the file that asks for them. + * + * Nothing but the test imports this. + */ + +export { GENERATORS } from "../src/camo/patterns"; diff --git a/web/scripts/camo-patterns.test.mjs b/web/scripts/camo-patterns.test.mjs new file mode 100644 index 0000000..0d44733 --- /dev/null +++ b/web/scripts/camo-patterns.test.mjs @@ -0,0 +1,112 @@ +/** + * A generator that renders one flat colour is the failure this file exists to + * catch, because nothing else reports it. The image is valid, the canvas is + * painted, no exception is thrown, and the only symptom is a swatch in the + * picker that looks like the Solid pattern. + * + * Reaction diffusion shipped that way. Gray-Scott only makes patterns along a + * narrow diagonal band of the feed/kill plane, it drew the two rates + * independently and mostly missed the band, and it seeded a fixed number of + * nucleation sites regardless of image area, which flooded the half-size + * thumbnail even when the rates were good. Both failures are silent, and both + * depend on the seed and the sliders, so a spot check of one render would not + * have found either. + * + * The sweep is therefore over parameters rather than over one image: every + * generator, at both the sizes the editor actually renders, across the corners + * and middle of the slider space and a spread of seeds. Solid is excluded by + * name — one colour is what it is for. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const web = join(new URL(".", import.meta.url).pathname, ".."); + +const dir = await mkdtemp(join(tmpdir(), "camo-patterns-")); +const bundle = join(dir, "fixture.mjs"); +await run( + join(web, "node_modules/.bin/rolldown"), + [ + join(web, "scripts/camo-patterns.fixture.ts"), + "--format", + "esm", + "--platform", + "node", + "--file", + bundle, + ], + { cwd: web }, +); + +const { GENERATORS } = await import(bundle); + +/** Node has no ImageData, and a generator only ever touches these three. */ +const image = (width, height) => ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), +}); + +/** The camo, and the half-size thumbnail the picker draws beside each name. */ +const SIZES = [ + [84, 72], + [42, 36], +]; + +/** Four distinct greys: a generator that bands is visible, a flat one is not. */ +const PALETTE = [ + [0, 0, 0], + [85, 85, 85], + [170, 170, 170], + [255, 255, 255], +]; + +const SEEDS = [1, 20260807, 3189639203]; +const SLIDER = [0, 0.5, 1]; + +/** Distinct colours in the render, and the share held by the commonest one. */ +function spread(img) { + const counts = new Map(); + for (let i = 0; i < img.data.length; i += 4) { + const key = (img.data[i] << 16) | (img.data[i + 1] << 8) | img.data[i + 2]; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const pixels = img.width * img.height; + return { colours: counts.size, top: Math.max(...counts.values()) / pixels }; +} + +for (const gen of GENERATORS) { + if (gen.id === "solid") continue; + test(`${gen.name} renders more than one colour`, () => { + for (const [w, h] of SIZES) { + for (const seed of SEEDS) { + for (const scale of SLIDER) { + for (const detail of SLIDER) { + const params = { seed, scale, contrast: 0.6, detail, angle: 0 }; + const img = image(w, h); + gen.render(img, PALETTE, params); + const { colours, top } = spread(img); + const where = `${gen.id} ${w}x${h} ${JSON.stringify(params)}`; + assert.ok(colours > 1, `${where}: one flat colour`); + // Two colours where one covers all but a few pixels is the same + // failure wearing a disguise, so the dominant colour is capped. + assert.ok( + top < 0.97, + `${where}: ${Math.round(top * 100)}% one colour`, + ); + } + } + } + } + }); +} + +test.after(() => rm(dir, { recursive: true, force: true })); -- 2.51.2