Theme regression tests
Tests for validation, theme identity, and the variables affected by an edit. Write counts are distinct from browser timing.
Source: tests/active-theme.test.ts
This file is part of the site codebase and is shown with its project imports. Read the design writeup for context.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CHROMA_MAX,
RADIUS_MAX,
adoptColourMode,
applyPreset,
clampFont,
clampTheme,
changedVars,
clampColour,
clampRadius,
clampShadows,
editColour,
editColourInBothModes,
editFont,
editFonts,
editLayout,
editRadius,
editShadow,
hashTheme,
loadTheme,
markCustom,
resetToDefault,
switchColourMode,
themeVars,
toggleColourMode,
type ActiveThemeState,
} from '../lib/theme/active-theme';
import { DEFAULT_THEME, THEME_PRESETS } from '../lib/theme/presets';
import { parseOklch } from '../lib/theme/color-utils';
import { generateTheme } from '../lib/theme/generate';
const OCEAN = THEME_PRESETS.find((preset) => preset.id === 'ocean')!;
const light = (): ActiveThemeState => ({ theme: DEFAULT_THEME, mode: 'light' });
const dark = (): ActiveThemeState => ({ theme: DEFAULT_THEME, mode: 'dark' });
const size = (vars: Record<string, string>): number => Object.keys(vars).length;
// ---------------------------------------------------------------------------
// The perf table from docs/theme-perf-baseline.md, asserted directly.
// A regression is a count that went up.
// ---------------------------------------------------------------------------
test('a radius edit writes one variable', () => {
const { vars } = editRadius(light(), 0.6);
assert.deepEqual(vars, { '--radius': '0.6rem' });
});
test('a colour edit writes one variable', () => {
const { vars } = editColour(light(), 'primary', 'oklch(0.5 0.2 250)');
// Already in range, so it is stored exactly as written — no reformatting.
assert.deepEqual(vars, { '--primary': 'oklch(0.5 0.2 250)' });
});
test('a font edit writes one variable', () => {
const { vars } = editFont(light(), 'sans', 'Inter, sans-serif');
assert.deepEqual(vars, { '--font-sans': 'Inter, sans-serif' });
});
test('replacing all fonts writes three variables', () => {
const fonts = { sans: 'A', serif: 'B', mono: 'C' };
const { vars } = editFonts(light(), fonts);
assert.equal(size(vars), 3);
});
test('a shadow edit writes all fourteen shadow variables', () => {
const { vars } = editShadow(light(), { blur: 12 });
assert.equal(size(vars), 14);
assert.equal(vars['--shadow-blur'], '12px');
});
test('a layout edit writes nothing but still counts as an edit', () => {
const { state, vars } = editLayout(light(), { navigation: 'side' });
assert.deepEqual(vars, {});
assert.equal(state.theme.layout.navigation, 'side');
assert.equal(state.theme.id, 'custom-default');
});
test('switching Colour Mode writes only the 32 colour variables', () => {
const { vars } = switchColourMode(light(), 'dark');
assert.equal(size(vars), 32);
assert.ok(!('--radius' in vars));
assert.ok(!('--shadow' in vars));
assert.ok(!('--font-sans' in vars));
});
test('a full Theme replacement writes 52 variables', () => {
assert.equal(size(themeVars(DEFAULT_THEME, 'light')), 52);
assert.equal(size(applyPreset(light(), OCEAN).vars), 52);
assert.equal(size(loadTheme(light(), OCEAN).vars), 52);
assert.equal(size(resetToDefault(light()).vars), 52);
});
test('editing a colour in both modes still costs one write', () => {
const { state, vars } = editColourInBothModes(light(), 'primary', '#ff0000');
assert.equal(size(vars), 1);
assert.ok('--primary' in vars);
// Both palettes changed, even though only one was written.
assert.notEqual(state.theme.light.primary, DEFAULT_THEME.light.primary);
assert.notEqual(state.theme.dark.primary, DEFAULT_THEME.dark.primary);
});
test('a colour edit in dark mode writes the dark value', () => {
const { state, vars } = editColour(dark(), 'background', 'oklch(0.2 0.05 250)');
assert.equal(vars['--background'], 'oklch(0.2 0.05 250)');
assert.equal(state.theme.light.background, DEFAULT_THEME.light.background);
});
// ---------------------------------------------------------------------------
// The filter (Q19): the write is filtered, the state never is.
// ---------------------------------------------------------------------------
test('changedVars drops variables already holding that value', () => {
const current = { '--radius': '0.5rem', '--primary': 'oklch(0.5 0.2 250)' };
const next = { '--radius': '0.5rem', '--primary': 'oklch(0.6 0.2 250)' };
assert.deepEqual(changedVars(next, current), { '--primary': 'oklch(0.6 0.2 250)' });
});
test('settling on the mode already showing writes nothing', () => {
const state = light();
const { vars } = switchColourMode(state, 'light');
const onDom = themeVars(state.theme, 'light');
assert.deepEqual(changedVars(vars, onDom), {});
});
test('re-picking the Preset already applied still resets identity', () => {
// Divergence is judged by what happened, not by what the values are.
const edited = editRadius({ theme: OCEAN, mode: 'light' }, OCEAN.radius);
assert.equal(edited.state.theme.id, 'custom-ocean');
const repicked = applyPreset(edited.state, OCEAN);
assert.equal(repicked.state.theme.id, 'ocean');
});
// ---------------------------------------------------------------------------
// Identity
// ---------------------------------------------------------------------------
test('editing Ocean yields Custom Ocean', () => {
const { state } = editRadius({ theme: OCEAN, mode: 'light' }, 0.9);
assert.equal(state.theme.id, 'custom-ocean');
assert.equal(state.theme.name, 'Custom Ocean');
});
test('markCustom is idempotent', () => {
const once = markCustom(OCEAN);
const twice = markCustom(once);
assert.equal(twice.id, 'custom-ocean');
assert.equal(twice.name, 'Custom Ocean');
assert.equal(markCustom(twice).name, 'Custom Ocean');
});
test('markCustom accepts a name without inventing a rename UI', () => {
assert.equal(markCustom(OCEAN, 'Client Brand').name, 'Client Brand');
assert.equal(markCustom(OCEAN, 'Client Brand').id, 'custom-ocean');
});
test('dialling every value back does not restore preset identity', () => {
const start: ActiveThemeState = { theme: OCEAN, mode: 'light' };
const away = editRadius(start, 1.2);
const back = editRadius(away.state, OCEAN.radius);
assert.deepEqual(back.state.theme.radius, OCEAN.radius);
assert.equal(back.state.theme.id, 'custom-ocean');
});
test('switching Colour Mode is not an edit', () => {
const { state } = toggleColourMode({ theme: OCEAN, mode: 'light' });
assert.equal(state.theme.id, 'ocean');
assert.equal(state.mode, 'dark');
});
test('theme ids are derived, never clocked', () => {
assert.equal(hashTheme(OCEAN), hashTheme(OCEAN));
assert.notEqual(hashTheme(OCEAN), hashTheme(DEFAULT_THEME));
assert.match(hashTheme(OCEAN), /^[0-9a-z]+$/);
});
// ---------------------------------------------------------------------------
// Validation policy (ADR-0002)
// ---------------------------------------------------------------------------
test('out-of-gamut OKLCH is clamped, not replaced with a default', () => {
const clamped = clampColour('oklch(99 99 999)', 'oklch(0.5 0.1 100)');
const parsed = parseOklch(clamped)!;
assert.equal(parsed.l, 1);
assert.equal(parsed.c, CHROMA_MAX);
assert.equal(parsed.h, 279); // 999 mod 360
});
test('negative hue wraps rather than clamping to zero', () => {
const parsed = parseOklch(clampColour('oklch(0.5 0.1 -30)', 'oklch(0.5 0.1 0)'))!;
assert.equal(parsed.h, 330);
});
test('a pasted hex colour is converted, not rejected', () => {
const converted = clampColour('#ff0000', 'oklch(0.5 0.1 100)');
const parsed = parseOklch(converted)!;
assert.ok(parsed.c > 0.1, 'red should not come out grey');
assert.notEqual(converted, 'oklch(0.5 0.1 100)');
});
test('rgb() and hsl() are converted too', () => {
const previous = 'oklch(0.5 0.1 100)';
assert.notEqual(clampColour('rgb(255, 0, 0)', previous), previous);
assert.notEqual(clampColour('hsl(120, 100%, 50%)', previous), previous);
assert.match(clampColour('rgb(255 0 0)', previous), /^oklch\(/);
});
test('unparseable input falls back to the previous value, not the default', () => {
const previous = 'oklch(0.4321 0.1234 200)';
assert.equal(clampColour('not a colour', previous), previous);
assert.equal(clampColour('', previous), previous);
assert.equal(clampColour(undefined, previous), previous);
assert.equal(clampColour(42, previous), previous);
});
test('radius clamps into range rather than falling back to the default', () => {
assert.equal(clampRadius(5, 0.5), RADIUS_MAX);
assert.equal(clampRadius(-3, 0.5), 0);
assert.equal(clampRadius(Number.NaN, 0.5), 0.5);
assert.equal(clampRadius('big' as unknown, 0.5), 0.5);
});
test('an out-of-range radius edit stores the clamped value', () => {
const { state, vars } = editRadius(light(), 99);
assert.equal(state.theme.radius, RADIUS_MAX);
assert.deepEqual(vars, { '--radius': '1.5rem' });
});
test('shadow patches clamp field by field against current shadows', () => {
const previous = DEFAULT_THEME.shadows;
const clamped = clampShadows({ opacity: 5, blur: -2 }, previous);
assert.equal(clamped.opacity, 1);
assert.equal(clamped.blur, 0);
assert.equal(clamped.x, previous.x, 'untouched fields are preserved');
assert.equal(clamped.color, previous.color);
});
test('state never holds a value the clamp would have rejected', () => {
const { state } = editColour(light(), 'primary', 'oklch(99 99 999)');
const parsed = parseOklch(state.theme.light.primary)!;
assert.ok(parsed.l <= 1 && parsed.c <= CHROMA_MAX && parsed.h < 360);
});
// ---------------------------------------------------------------------------
// Clamping is at every operation boundary, not just the single-field edits
// ---------------------------------------------------------------------------
test('an already-valid colour is preserved byte for byte', () => {
// Reformatting in-range values would churn share links and CSS exports.
for (const input of ['oklch(0.5 0.2 250)', 'oklch(1 0 0)', 'oklch(0.9711 0 0)']) {
assert.equal(clampColour(input, 'oklch(0.1 0.1 1)'), input);
}
});
test('the alpha form survives the clamp', () => {
// Dark palettes use translucent borders; the shared parser cannot read these.
assert.equal(clampColour('oklch(1 0 0 / 10%)', 'oklch(0.5 0 0)'), 'oklch(1 0 0 / 10%)');
assert.equal(clampColour('oklch(1 0 0 / 0.15)', 'oklch(0.5 0 0)'), 'oklch(1 0 0 / 0.15)');
});
test('an out-of-range alpha colour is clamped but keeps its alpha', () => {
const out = clampColour('oklch(5 9 400 / 10%)', 'oklch(0.5 0 0)');
assert.equal(out, 'oklch(1.0000 0.4000 40.0000 / 10%)');
});
test('loadTheme clamps what it is given', () => {
const dirty = {
...DEFAULT_THEME,
id: 'from-a-share-link',
radius: 99,
light: { ...DEFAULT_THEME.light, primary: 'oklch(9 9 900)' },
};
const { state } = loadTheme(light(), dirty);
assert.equal(state.theme.radius, RADIUS_MAX);
const parsed = parseOklch(state.theme.light.primary)!;
assert.ok(parsed.l <= 1 && parsed.c <= CHROMA_MAX && parsed.h < 360);
});
test('applyPreset clamps too — the policy is untrusted by default', () => {
const rogue = { ...OCEAN, radius: 42 };
assert.equal(applyPreset(light(), rogue).state.theme.radius, RADIUS_MAX);
});
test('a real preset passes through clamping unchanged', () => {
// If clamping altered presets, every preset pick would dirty the share link.
assert.deepEqual(clampTheme(OCEAN, DEFAULT_THEME), OCEAN);
assert.deepEqual(clampTheme(DEFAULT_THEME, DEFAULT_THEME), DEFAULT_THEME);
});
test('fonts fall back to the previous value rather than blanking', () => {
assert.equal(clampFont(' Inter, sans-serif ', 'Georgia'), 'Inter, sans-serif');
assert.equal(clampFont('', 'Georgia'), 'Georgia');
assert.equal(clampFont(undefined, 'Georgia'), 'Georgia');
assert.equal(editFont(light(), 'sans', '').state.theme.fonts.sans, DEFAULT_THEME.fonts.sans);
});
// ---------------------------------------------------------------------------
// Colour Mode: chosen vs derived
// ---------------------------------------------------------------------------
test('adopting and switching produce the same state and the same writes', () => {
// They differ only in whether the React layer remembers the result.
const adopted = adoptColourMode(light(), 'dark');
const switched = switchColourMode(light(), 'dark');
assert.deepEqual(adopted.state, switched.state);
assert.deepEqual(adopted.vars, switched.vars);
});
test('adopting a mode is not an edit', () => {
assert.equal(adoptColourMode({ theme: OCEAN, mode: 'light' }, 'dark').state.theme.id, 'ocean');
});
// ---------------------------------------------------------------------------
// Colour-space conversion edge cases (found in PR review)
// ---------------------------------------------------------------------------
test('HSL hue wraps rather than turning black', () => {
// hsl(720) is hsl(0) is red. Un-normalised it converted to black — and black
// is in gamut, so the clamp would have let it onto the page.
const previous = 'oklch(0.5 0.1 100)';
const red = clampColour('hsl(0, 100%, 50%)', previous);
assert.equal(clampColour('hsl(360, 100%, 50%)', previous), red);
assert.equal(clampColour('hsl(720, 100%, 50%)', previous), red);
assert.notEqual(red, 'oklch(0.0000 0.0000 0.0000)');
});
test('a negative HSL hue wraps instead of falling back', () => {
const previous = 'oklch(0.5 0.1 100)';
assert.equal(
clampColour('hsl(-120, 100%, 50%)', previous),
clampColour('hsl(240, 100%, 50%)', previous)
);
});
test('generated Themes with one description do not collide on id', () => {
const base = {
primaryHue: 250,
primaryChroma: 0.15,
primaryLightness: 0.5,
accentHue: 200,
radius: 0.5,
mood: 'bold',
description: 'calm ocean',
};
const a = generateTheme(base);
const b = generateTheme({ ...base, primaryHue: 20, mood: 'elegant' });
assert.notEqual(a.id, b.id, 'different Themes must not share an id');
assert.match(a.id, /^custom-calm-ocean-/, 'the slug stays readable');
// Still pure: same parameters in, same identity out.
assert.equal(generateTheme(base).id, a.id);
});
test('a generated Theme is already Custom, so editing does not re-prefix it', () => {
const theme = generateTheme({
primaryHue: 250, primaryChroma: 0.15, primaryLightness: 0.5,
accentHue: 200, radius: 0.5, mood: 'bold', description: 'calm ocean',
});
assert.equal(markCustom(theme).id, theme.id);
});