import { readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { afterAll, describe, expect, it } from "vitest"; import type { BorderRadius } from "./knobs"; import { radiusOverrideFor, resolveRadiusClass, resolveRadiusForPart } from "./radiusClass"; import { OPEN_RADIUS_ADJUDICATIONS, RADIUS_OVERRIDE_TABLE, RADIUS_RESOLUTION_COMPOSITION, RADIUS_RESOLUTION_STOPS, RADIUS_RESOLUTION_TABLE, } from "./radiusClassTable.generated"; import { capContainerRadius } from "./resolveKnobs"; // The generator is the doctrine's reading end — the spec imports it directly so a // doctrine edit and a generated-table edit are compared in the same run. Vite // rewrites import.meta.url to an http URL, so we resolve from cwd (public/theme) // and force a file: specifier the ESM loader will accept. const registry = await import( pathToFileURL(join(process.cwd(), "../../scripts/radius-class-registry.mjs")).href ); const doctrine = registry.readRadiusClassDoctrine(); const STOPS = ["none", "small", "medium", "large", "full"] as const; // Sample geometry: a 44px control (h/2 = 22) and the three space-stop paddings // the doctrine names (13/18/32 — panelPaddingPxMap). const H = 44; const PADS = { small: 13, medium: 18, large: 32 } as const; describe("the doctrine table, cell by cell (DG-RAD-01..04)", () => { const expected: Record> = { DEFAULT: { none: 0, small: 5, medium: 9, large: 16, full: 50 }, "CIRCULAR-AT-FULL": { none: 0, small: 5, medium: 9, large: 16, full: H / 2 }, BINARY: { none: 0, small: H / 2, medium: H / 2, large: H / 2, full: H / 2 }, }; for (const [radiusClass, row] of Object.entries(expected)) { for (const stop of STOPS) { it(`${radiusClass} at ${stop} resolves to ${row[stop]}`, () => { expect( resolveRadiusClass(radiusClass as keyof typeof RADIUS_RESOLUTION_TABLE, stop, { heightPx: H, }), ).toBe(row[stop]); }); } } const containerCap: Record> = { small: { none: 0, small: 5, medium: 9, large: 13, full: 13 }, medium: { none: 0, small: 5, medium: 9, large: 16, full: 18 }, large: { none: 0, small: 5, medium: 9, large: 16, full: 32 }, }; for (const space of Object.keys(containerCap) as (keyof typeof PADS)[]) { for (const stop of STOPS) { it(`CONTAINER-CAP at ${stop} under space=${space} resolves to ${containerCap[space][stop]}`, () => { expect(resolveRadiusClass("CONTAINER-CAP", stop, { paddingPx: PADS[space] })).toBe( containerCap[space][stop], ); }); } } }); describe("capContainerRadius agrees with the CONTAINER-CAP column (DG-RAD-04, unchanged)", () => { // capContainerRadius returns the TOKEN while it fits and the padding px once // it would exceed; mirror the token px scale to compare resolutions. const tokenPx: Record = { $0: 0, $2: 5, $4: 9, $6: 16, $12: 50 }; const asPx = (value: string | number): number => typeof value === "number" ? value : tokenPx[value]; for (const space of Object.keys(PADS) as (keyof typeof PADS)[]) { for (const stop of STOPS) { it(`at ${stop} under space=${space}`, () => { expect(asPx(capContainerRadius(stop, space))).toBe( resolveRadiusClass("CONTAINER-CAP", stop, { paddingPx: PADS[space] }), ); }); } } it("the cap is the container's own padding at full, under every space", () => { expect(capContainerRadius("full", "small")).toBe(13); expect(capContainerRadius("full", "medium")).toBe(18); expect(capContainerRadius("full", "large")).toBe(32); }); it("the cap bites at large under space=small (16 > 13) and nowhere else below full", () => { expect(capContainerRadius("large", "small")).toBe(13); expect(capContainerRadius("large", "medium")).toBe("$6"); expect(capContainerRadius("large", "large")).toBe("$6"); expect(capContainerRadius("medium", "small")).toBe("$4"); expect(capContainerRadius("small", "small")).toBe("$2"); expect(capContainerRadius("none", "small")).toBe("$0"); }); }); describe("the generated table is the doctrine, not a hand-kept copy", () => { it("RADIUS_RESOLUTION_TABLE matches a fresh parse of docs/design-guidelines.md", () => { expect(JSON.parse(JSON.stringify(RADIUS_RESOLUTION_TABLE))).toEqual(doctrine.classes); }); it("RADIUS_OVERRIDE_TABLE matches the doctrine's DG-RAD-05 table", () => { expect(JSON.parse(JSON.stringify(RADIUS_OVERRIDE_TABLE))).toEqual(doctrine.overrides); }); it("OPEN_RADIUS_ADJUDICATIONS matches the doctrine's open list", () => { expect(JSON.parse(JSON.stringify(OPEN_RADIUS_ADJUDICATIONS))).toEqual( doctrine.openAdjudications, ); }); it("the stops and the composition order are the doctrine's", () => { expect([...RADIUS_RESOLUTION_STOPS]).toEqual(doctrine.stops); expect([...RADIUS_RESOLUTION_COMPOSITION]).toEqual(doctrine.composition); }); it("the class set is exactly the four ruled classes — no silent widening", () => { expect(Object.keys(RADIUS_RESOLUTION_TABLE).sort()).toEqual([ "BINARY", "CIRCULAR-AT-FULL", "CONTAINER-CAP", "DEFAULT", ]); }); }); describe("a doctrine edit the code cannot spell FAILS loudly", () => { const tempPaths: string[] = []; afterAll(() => { for (const path of tempPaths) rmSync(path, { force: true }); }); const parseMutated = (mutate: (source: string) => string) => { const source = readFileSync(registry.DOCTRINE_PATH, "utf-8"); const mutated = mutate(source); expect(mutated).not.toBe(source); // the mutation must have landed const path = join(tmpdir(), `dg-rad-spec-${process.pid}-${tempPaths.length}.md`); writeFileSync(path, mutated); tempPaths.push(path); return () => registry.readRadiusClassDoctrine(path); }; it("rejects a cell spelling outside the closed grammar", () => { expect( parseMutated((s) => s.replace("| BINARY | 0 | h/2", "| BINARY | 0 | thumb-ish"), ), ).toThrow(/not a spelling the radius registry can read/); }); it("rejects a class that resolves `none` to nonzero (DG-RAD-01)", () => { expect( parseMutated((s) => s.replace("| BINARY | 0 |", "| BINARY | 3 |")), ).toThrow(/does not resolve `none` to 0/); }); it("rejects a table that loses one of the four ruled classes", () => { expect( parseMutated((s) => s.replace("| CIRCULAR-AT-FULL | 0", "| CIRCLE-ISH-MAYBE | 0")), ).toThrow(/missing CIRCULAR-AT-FULL/); }); it("rejects a Checkbox override spelled h/2 — R2b is a ruling, not a cell", () => { expect( parseMutated((s) => s.replace( "| Checkbox | 0 | 5 | 5 | 5 | 5", "| Checkbox | 0 | 5 | 5 | 5 | h/2", ), ), ).toThrow(/Checkbox override spells h\/2/); }); it("rejects a Radio disc override that is not h/2 — radio is always round (R2c)", () => { expect( parseMutated((s) => s.replace( "| Radio disc | h/2 | h/2 | h/2 | h/2 | h/2", "| Radio disc | 0 | h/2 | h/2 | h/2 | h/2", ), ), ).toThrow(/Radio disc override is not h\/2/); }); it("rejects a doctrine that drops the composition order sentence", () => { expect( parseMutated((s) => s.replace( "immunity wins, then a declared override, then the class", "the class wins, probably", ), ), ).toThrow(/composition note no longer spells the order/); }); it("resolveRadiusClass throws on a class the doctrine never ruled", () => { expect(() => resolveRadiusClass("R-VIBES" as never, "full")).toThrow(/not a class/); }); it("resolveRadiusClass throws on a stop the table does not know", () => { expect(() => resolveRadiusClass("DEFAULT", "extra-full" as never)).toThrow(/not a radius stop/); }); it("h/2 cells demand the part's own painted height", () => { expect(() => resolveRadiusClass("BINARY", "small")).toThrow(/needs geometry\.heightPx/); expect(() => resolveRadiusClass("CIRCULAR-AT-FULL", "full", {})).toThrow( /needs geometry\.heightPx/, ); expect(() => resolveRadiusClass("BINARY", "small", { heightPx: 0 })).toThrow( /needs geometry\.heightPx/, ); }); it("CONTAINER-CAP cells demand the container's own padding", () => { expect(() => resolveRadiusClass("CONTAINER-CAP", "full")).toThrow(/needs geometry\.paddingPx/); expect(() => resolveRadiusClass("CONTAINER-CAP", "small", { heightPx: H })).toThrow( /needs geometry\.paddingPx/, ); }); }); describe("the override layer wins over the class layer (DG-RAD-05)", () => { it("Checkbox measures a square-cornered box at every stop, never h/2 (R2b)", () => { const glyph = 20; // the checkbox glyph is 20×20, so h/2 would be 10 for (const stop of STOPS) { const resolved = resolveRadiusForPart({ part: "Checkbox", radiusClass: "CIRCULAR-AT-FULL", // its kin class by shape — the override must beat it stop, heightPx: glyph, }); expect(resolved.source).toBe("override"); expect(resolved.px).toBe(stop === "none" ? 0 : 5); expect(resolved.px).not.toBe(glyph / 2); expect(resolved.overrideWhy).toMatch(/reads as a radio/); } }); it("Checkbox at full is the clamp (5), not the kin circle (10) and not the token (50)", () => { const resolved = resolveRadiusForPart({ part: "Checkbox", radiusClass: "CIRCULAR-AT-FULL", stop: "full", heightPx: 20, }); expect(resolved.px).toBe(5); }); it("the Radio disc is round at every stop, including none (R2c — R2b inverted)", () => { const disc = 20; for (const stop of STOPS) { const resolved = resolveRadiusForPart({ part: "Radio disc", radiusClass: "BINARY", stop, heightPx: disc, }); expect(resolved.source).toBe("override"); expect(resolved.px).toBe(disc / 2); } }); it("override lookup matches the doctrine spelling case-insensitively", () => { expect(radiusOverrideFor("checkbox")?.component).toBe("Checkbox"); expect(radiusOverrideFor("RADIO DISC")?.component).toBe("Radio disc"); expect(radiusOverrideFor("Button")).toBeUndefined(); }); it("an UNDECLARED part still resolves by its class", () => { const resolved = resolveRadiusForPart({ part: "Button", radiusClass: "DEFAULT", stop: "full" }); expect(resolved.source).toBe("class"); expect(resolved.px).toBe(50); expect(resolved.overrideWhy).toBeUndefined(); // and with no part at all, the class is the resolution expect(resolveRadiusForPart({ radiusClass: "BINARY", stop: "medium", heightPx: H }).px).toBe( H / 2, ); }); }); describe("knob-immunity COMPOSES above resolution (LC-60 / D-09, R2a)", () => { it("a declared R-PILL mark stays round at none — the knob never reaches it", () => { const dot = 8; const resolved = resolveRadiusForPart({ part: "Badge count/dot", radiusClass: "DEFAULT", immunity: "R-PILL", stop: "none", heightPx: dot, }); expect(resolved.source).toBe("immunity"); expect(resolved.px).toBe(dot / 2); // round, not the class table's 0 }); it("a declared R-IDENTITY part stays round at every stop", () => { for (const stop of STOPS) { const resolved = resolveRadiusForPart({ part: "Avatar", radiusClass: "CIRCULAR-AT-FULL", immunity: "R-IDENTITY", stop, heightPx: 40, }); expect(resolved.source).toBe("immunity"); expect(resolved.px).toBe(20); } }); it("immunity wins over a declared override — composed, never replaced", () => { const resolved = resolveRadiusForPart({ part: "Checkbox", immunity: "R-IDENTITY", stop: "full", heightPx: 20, }); expect(resolved.source).toBe("immunity"); expect(resolved.px).toBe(10); }); it("immunity is round geometry, so it demands the painted height", () => { expect(() => resolveRadiusForPart({ part: "Badge count/dot", immunity: "R-PILL", stop: "none" }), ).toThrow(/needs geometry\.heightPx/); }); it("the doctrine's layering order is immunity, then override, then class", () => { expect([...RADIUS_RESOLUTION_COMPOSITION]).toEqual(["immunity", "override", "class"]); }); }); describe("open adjudications are REPORTED, never silently skipped (MPO-56 AC-6)", () => { it("the exclusion list is empty — R2 leftovers closed by RULINGS-2026-08-28", () => { expect(OPEN_RADIUS_ADJUDICATIONS).toEqual([]); }); }); describe("closed R2 leftovers (RULINGS-2026-08-28)", () => { it("a single-line control at full is the DEFAULT pill token (50), not clampRadiusForControl's 16 (R2d)", () => { const resolved = resolveRadiusForPart({ part: "Input", radiusClass: "DEFAULT", stop: "full", heightPx: 44, }); expect(resolved.source).toBe("class"); expect(resolved.px).toBe(50); expect(resolved.px).not.toBe(16); }); it("an icon well rides DEFAULT — square at none, token on the scale, no override (R2e)", () => { expect(radiusOverrideFor("icon well")).toBeUndefined(); expect(radiusOverrideFor("empty icon well")).toBeUndefined(); const atNone = resolveRadiusForPart({ part: "empty icon well", radiusClass: "DEFAULT", stop: "none", heightPx: 44, }); expect(atNone.source).toBe("class"); expect(atNone.px).toBe(0); expect( resolveRadiusForPart({ part: "empty icon well", radiusClass: "DEFAULT", stop: "medium" }).px, ).toBe(9); expect( resolveRadiusForPart({ part: "empty icon well", radiusClass: "DEFAULT", stop: "full" }).px, ).toBe(50); }); it("BINARY at none is 0 for thumb AND track — they square together (R2f)", () => { for (const part of ["Switch thumb", "Switch track", "Slider thumb", "Slider rail"]) { const resolved = resolveRadiusForPart({ part, radiusClass: "BINARY", stop: "none", heightPx: 28, }); expect(resolved.source).toBe("class"); expect(resolved.px).toBe(0); } expect( resolveRadiusForPart({ part: "Switch track", radiusClass: "BINARY", stop: "small", heightPx: 28, }).px, ).toBe(14); }); });