diff --git a/web/scripts/camo-reflow.test.mjs b/web/scripts/camo-reflow.test.mjs new file mode 100644 index 0000000..fea0876 --- /dev/null +++ b/web/scripts/camo-reflow.test.mjs @@ -0,0 +1,129 @@ +/** + * The unit grid must not push the page sideways on a phone. + * + * `.camo-fleet` is three columns of actual-size sprites, and actual size is the + * point: it is where a player finds out whether their camo survives being drawn + * small. That makes its width a hard floor rather than something the layout can + * squeeze, and a grid with a floor inside a flex row does not wrap โ€” it overran + * the document. At 320px the whole page scrolled sideways by 14px, and every + * 360px phone scrolled too. + * + * WCAG 1.4.10 puts the line at 320px, so the rule is: at 320px, three columns + * either fit or there are not three columns. + * + * The arithmetic is here rather than a remembered breakpoint, because the + * number moves the moment anyone touches the sprite size or a padding. If this + * fails, it prints what the grid now needs and what the viewport has left. + * + * It counts sprites and chrome, not the unit names under them, so its numbers + * come out a little under what a browser measures โ€” "Medium Mek ยท quad" is + * wider than the sprite it labels and widens that column. That makes this a + * floor: passing here is necessary and a browser is still the thing that says + * the page does not scroll. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const src = fileURLToPath(new URL("../src/", import.meta.url)); +const css = await readFile(`${src}styles.css`, "utf8"); +const mech = await readFile(`${src}camo/mech.ts`, "utf8"); + +/** WCAG 1.4.10 reflow: no horizontal scrolling at this width. */ +const REFLOW = 320; +const ROOT_FONT = 16; + +function number(source, pattern, what) { + const found = pattern.exec(source); + assert.ok(found, `${what} moved or was renamed`); + return Number(found[1]); +} + +/** A CSS length in px. Only px and rem appear in these rules. */ +function length(raw, what) { + const found = /^([\d.]+)(px|rem)$/.exec(raw.trim()); + assert.ok(found, `${what} is ${raw}, which this test cannot measure`); + return Number(found[1]) * (found[2] === "rem" ? ROOT_FONT : 1); +} + +/** The body of a rule, by its selector. */ +function rule(selector) { + const start = css.indexOf(`\n${selector} {`); + assert.notEqual(start, -1, `${selector} moved or was renamed`); + const end = css.indexOf("\n}", start); + return css.slice(start, end); +} + +function shorthandInline(body, property, what) { + const found = new RegExp(`${property}:\\s*([^;]+);`).exec(body); + assert.ok(found, `${what} moved or was renamed`); + const parts = found[1].trim().split(/\s+/); + // One value applies to both sides; two are block then inline. + return length(parts.length > 1 ? parts[1] : parts[0], what); +} + +test("three columns of units fit at 320px, or there are not three", () => { + const sprite = number(mech, /SPRITE_W = (\d+)/, "SPRITE_W"); + + const button = rule(".camo-unit-btn"); + const buttonPad = shorthandInline( + button, + "padding", + ".camo-unit-btn padding", + ); + const buttonBorder = length( + /border:\s*([\d.]+(?:px|rem))/.exec(button)[1], + ".camo-unit-btn border", + ); + const gap = length( + /gap:\s*([^;]+);/.exec(rule(".camo-fleet"))[1], + ".camo-fleet gap", + ); + + // Everything the page and the card spend either side of the grid. + const around = + 2 * shorthandInline(rule("#app"), "padding", "#app padding") + + 2 * shorthandInline(rule(".card"), "padding", ".card padding"); + + const column = sprite + 2 * buttonPad + 2 * buttonBorder; + const threeWide = 3 * column + 2 * gap; + const available = REFLOW - around; + + if (threeWide <= available) return; // three still fit; nothing to reflow + + // They do not fit, so a narrow-width rule has to take a column away, and it + // has to fire before the grid stops fitting rather than at some width + // somebody liked the look of. + const narrow = + /@media \(max-width: (\d+)px\) \{\s*\.camo-fleet \{\s*grid-template-columns: repeat\((\d)/.exec( + css, + ); + assert.ok( + narrow, + `three columns need ${threeWide}px and a ${REFLOW}px viewport leaves ${available}px, ` + + `but no @media rule reduces .camo-fleet's columns`, + ); + + const [, threshold, columns] = narrow; + assert.ok( + Number(columns) < 3, + `the narrow rule still asks for ${columns} columns`, + ); + // The width at which three columns stop fitting. The breakpoint has to be at + // least this wide or there is a band of phones still scrolling. + const breaksAt = threeWide + around; + assert.ok( + Number(threshold) >= breaksAt, + `.camo-fleet drops to ${columns} columns at ${threshold}px, but three stop ` + + `fitting at ${breaksAt}px โ€” viewports between those two scroll sideways`, + ); + + const fewer = Number(columns) * column + (Number(columns) - 1) * gap; + assert.ok( + fewer <= available, + `${columns} columns need ${fewer}px and ${REFLOW}px leaves ${available}px`, + ); +});