From 374c822f5e8014b83ae53607bdcc42daab5e3c13 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 12 Aug 2026 11:59:15 -0400 Subject: [PATCH] test(web): cover the preset groups and the MegaMek camo index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things here fail quietly. The picker marks a tile chosen by comparing its label to the palette's name, so two presets sharing a name light up together and neither is wrong enough to notice. And the camo files come from a script nobody runs on a normal day, so a half-finished run or an upstream rename leaves the index naming files that are not there — which surfaces as a broken thumbnail in a dialog, long after the commit that caused it. Co-Authored-By: Claude Opus 5 (1M context) --- web/scripts/camo-presets.fixture.ts | 12 ++ web/scripts/camo-presets.test.mjs | 179 ++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 web/scripts/camo-presets.fixture.ts create mode 100644 web/scripts/camo-presets.test.mjs diff --git a/web/scripts/camo-presets.fixture.ts b/web/scripts/camo-presets.fixture.ts new file mode 100644 index 0000000..b033b7b --- /dev/null +++ b/web/scripts/camo-presets.fixture.ts @@ -0,0 +1,12 @@ +/** + * What camo-presets.test.mjs needs, in one module it can bundle. + * + * palette.ts imports ./rng without an extension, which Node's own TypeScript + * stripping will not resolve, so the test bundles this the way the site does. + * Same reasoning as camo-record.fixture.ts, and it lives here for the same + * reason: a bundler resolves relative to the file that asks. + * + * Nothing but the test imports this. + */ + +export { PALETTE_GROUPS, PRESETS } from "../src/camo/palette"; diff --git a/web/scripts/camo-presets.test.mjs b/web/scripts/camo-presets.test.mjs new file mode 100644 index 0000000..a55cd12 --- /dev/null +++ b/web/scripts/camo-presets.test.mjs @@ -0,0 +1,179 @@ +/** + * The grouped preset picker, and the MegaMek camo directory it sits next to. + * + * Two things here are easy to break silently. The picker marks a tile chosen + * by comparing its label to the palette's name, so two presets sharing a name + * light up together and neither is wrong enough to notice. And the camo files + * are generated from a MegaMek release by a script nobody runs on a normal + * day, so a half-finished run, a rename upstream or a stray delete leaves an + * index naming files that are not there — which fails as a broken thumbnail in + * a dialog, long after the commit that caused it. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { access, mkdtemp, readFile } 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"; +import { fileURLToPath } from "node:url"; + +import { CURATED } from "./megamek-camo.data.mjs"; + +const run = promisify(execFile); +const web = fileURLToPath(new URL("../", import.meta.url)); +const CAMO_DIR = join(web, "public/megamek-camo"); + +// palette.ts is TypeScript importing TypeScript, so it is bundled with the +// site's own bundler rather than run through a TS runner. Same shape as +// camo-record.test.mjs; the output goes to a temp directory. +const dir = await mkdtemp(join(tmpdir(), "camo-presets-")); +const bundle = join(dir, "fixture.mjs"); +await run( + join(web, "node_modules/.bin/rolldown"), + [ + join(web, "scripts/camo-presets.fixture.ts"), + "--format", + "esm", + "--platform", + "node", + "--file", + bundle, + ], + { cwd: web }, +); + +const { PALETTE_GROUPS, PRESETS } = await import(bundle); + +test("every preset is in a group the picker draws a chip for", () => { + for (const preset of PRESETS) { + assert.ok( + preset.group, + `${preset.name} has no group, so no chip shows it and it is unreachable`, + ); + assert.ok( + PALETTE_GROUPS.includes(preset.group), + `${preset.name} is in "${preset.group}", which is not in PALETTE_GROUPS`, + ); + } + for (const group of PALETTE_GROUPS) { + assert.ok( + PRESETS.some((p) => p.group === group), + `the "${group}" chip would open an empty grid`, + ); + } +}); + +test("preset names are unique", () => { + const seen = new Set(); + for (const preset of PRESETS) { + assert.ok( + !seen.has(preset.name), + `two presets are called "${preset.name}"; the picker marks the chosen ` + + "tile by name, so both would look chosen", + ); + seen.add(preset.name); + } +}); + +test("every preset is four colours of whole 0-255 channels", () => { + for (const preset of PRESETS) { + assert.equal( + preset.colors.length, + 4, + `${preset.name} has ${preset.colors.length} colours; the tile draws four bands`, + ); + for (const color of preset.colors) { + assert.equal( + color.length, + 3, + `${preset.name} has a colour that is not RGB`, + ); + for (const channel of color) { + assert.ok( + Number.isInteger(channel) && channel >= 0 && channel <= 255, + `${preset.name} has channel ${channel}, which the record encoder rejects`, + ); + } + } + } +}); + +test("flags are Pride palettes and nothing else is", () => { + for (const preset of PRESETS) { + assert.equal( + preset.flag === true, + preset.group === "Pride", + `${preset.name} disagrees with itself: flag is ${preset.flag} and the ` + + `group is "${preset.group}". randomCamo excludes flags, so a faction ` + + "marked as one would silently drop out of the roll", + ); + } +}); + +test("the roll still excludes flags", async () => { + const random = await readFile(join(web, "src/camo/random.ts"), "utf8"); + assert.match( + random, + /PRESETS\.filter\(\(p\) => !p\.flag\)/, + "a flag is something a person picks on purpose, not something the page " + + "hands them on load", + ); +}); + +test("the camo index names files that are on disk", async () => { + const index = JSON.parse( + await readFile(join(CAMO_DIR, "index.json"), "utf8"), + ); + assert.ok(index.release, "the index does not say which release it came from"); + + for (const category of index.categories) { + assert.ok(category.camos.length > 0, `"${category.name}" is empty`); + for (const camo of category.camos) { + await assert.doesNotReject( + access(join(CAMO_DIR, camo.file)), + `${camo.file} is in the index and not in public/megamek-camo/`, + ); + } + } +}); + +test("the index carries every curated entry", async () => { + const index = JSON.parse( + await readFile(join(CAMO_DIR, "index.json"), "utf8"), + ); + for (const group of CURATED) { + const category = index.categories.find((c) => c.name === group.name); + assert.ok(category, `"${group.name}" is curated and not in the index`); + for (const entry of group.entries) { + assert.ok( + category.camos.some( + (c) => c.name === entry.name && c.src === entry.src, + ), + `${entry.name} is curated and not in the index; re-run ` + + "`MM_HOME=... npm run megamek-camo`", + ); + } + } +}); + +test("every faction the script derives a palette for has one", () => { + for (const group of CURATED.filter((g) => g.palettes)) { + for (const entry of group.entries) { + const preset = PRESETS.find((p) => p.name === entry.name); + assert.ok( + preset, + `${entry.name} has camo art but no palette; the script prints the ` + + "block, and pasting it into palette.ts is a manual step", + ); + assert.equal( + preset.group, + group.name, + `${entry.name} is filed under "${preset.group}" and its art under ` + + `"${group.name}"`, + ); + } + } +}); -- 2.51.2