Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions configurator/tests/colorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Unit tests for src/lib/colorUtils.ts — the colour maths behind every picker,
* the WCAG panel, and the exported palette. Pure functions, no DOM.
*
* Why this matters: these are ~7 pure functions with zero unit coverage. A
* silent regression (a flipped coefficient, a gamma tweak, a rounding change)
* would corrupt EVERY resolved colour in the configurator and only surface as
* a wrong export in a user's hands. These tests pin the contract.
*/
import { describe, test, expect } from 'vitest';
import {
oklchToRgb,
rgbToHex,
getRelativeLuminance,
getContrastRatio,
getContrastRating,
parseOklch,
stringifyOklch,
} from '../src/lib/colorUtils';

describe('oklchToRgb', () => {
test('pure white: L=1, C=0 → [255,255,255]', () => {
expect(oklchToRgb(1, 0, 0)).toEqual([255, 255, 255]);
});

test('pure black: L=0, C=0 → [0,0,0]', () => {
expect(oklchToRgb(0, 0, 0)).toEqual([0, 0, 0]);
});

test('output is always clamped into the [0,255] byte range', () => {
// Sweep hues and a high chroma that pushes several hues out of sRGB gamut;
// the function must clamp, never emit NaN or out-of-range bytes.
for (let h = 0; h < 360; h += 15) {
const rgb = oklchToRgb(0.6, 0.37, h);
expect(rgb).toHaveLength(3);
for (const ch of rgb) {
expect(Number.isInteger(ch)).toBe(true);
expect(ch).toBeGreaterThanOrEqual(0);
expect(ch).toBeLessThanOrEqual(255);
}
}
});

test('a mid red hue skews the red channel highest', () => {
const [r, g, b] = oklchToRgb(0.63, 0.25, 29); // ~ sRGB red
expect(r).toBeGreaterThan(g);
expect(r).toBeGreaterThan(b);
});
});

describe('rgbToHex', () => {
test('pads single-digit channels to two hex digits', () => {
expect(rgbToHex(0, 0, 0)).toBe('#000000');
expect(rgbToHex(255, 255, 255)).toBe('#ffffff');
expect(rgbToHex(1, 2, 3)).toBe('#010203');
});
});

describe('getRelativeLuminance', () => {
test('white luminance is 1, black is 0', () => {
expect(getRelativeLuminance(255, 255, 255)).toBeCloseTo(1, 5);
expect(getRelativeLuminance(0, 0, 0)).toBeCloseTo(0, 5);
});

test('green weighs more than red weighs more than blue', () => {
const r = getRelativeLuminance(255, 0, 0);
const g = getRelativeLuminance(0, 255, 0);
const b = getRelativeLuminance(0, 0, 255);
expect(g).toBeGreaterThan(r);
expect(r).toBeGreaterThan(b);
});
});

describe('getContrastRatio', () => {
test('black-on-white is the canonical 21:1', () => {
const white = getRelativeLuminance(255, 255, 255);
const black = getRelativeLuminance(0, 0, 0);
expect(getContrastRatio(white, black)).toBeCloseTo(21, 1);
});

test('identical colours give the 1:1 floor and order does not matter', () => {
const l = getRelativeLuminance(128, 128, 128);
expect(getContrastRatio(l, l)).toBeCloseTo(1, 5);
const a = getRelativeLuminance(255, 255, 255);
const b = getRelativeLuminance(0, 0, 0);
expect(getContrastRatio(a, b)).toBeCloseTo(getContrastRatio(b, a), 5);
});
});

describe('getContrastRating', () => {
test('21:1 passes every WCAG tier', () => {
const r = getContrastRating(21);
expect(r).toMatchObject({
aaNormal: 'PASS', aaLarge: 'PASS', aaaNormal: 'PASS', aaaLarge: 'PASS',
});
expect(r.ratioText).toBe('21.00:1');
});

test('boundary values map to the exact spec thresholds', () => {
// 4.5 is the AA-normal / AAA-large floor; below it both fail.
expect(getContrastRating(4.5)).toMatchObject({ aaNormal: 'PASS', aaaLarge: 'PASS', aaaNormal: 'FAIL' });
expect(getContrastRating(4.49)).toMatchObject({ aaNormal: 'FAIL', aaaLarge: 'FAIL' });
// 3.0 is the AA-large floor; 7.0 is the AAA-normal floor.
expect(getContrastRating(3.0).aaLarge).toBe('PASS');
expect(getContrastRating(2.99).aaLarge).toBe('FAIL');
expect(getContrastRating(7.0).aaaNormal).toBe('PASS');
});
});

describe('parseOklch ↔ stringifyOklch', () => {
test('parses a canonical oklch() string', () => {
expect(parseOklch('oklch(0.7 0.15 200)')).toEqual({ l: 0.7, c: 0.15, h: 200, valid: true });
});

test('accepts a percentage lightness', () => {
const p = parseOklch('oklch(70% 0.15 200)');
expect(p.valid).toBe(true);
expect(p.l).toBeCloseTo(0.7, 5);
});

test('non-oklch input returns the documented fallback with valid:false', () => {
const p = parseOklch('#ff0000');
expect(p.valid).toBe(false);
expect(p).toMatchObject({ l: 0.5, c: 0.15, h: 200 });
});

test('stringify → parse round-trips the components', () => {
const s = stringifyOklch(0.732, 0.123, 275);
expect(s).toBe('oklch(0.732 0.123 275)');
const p = parseOklch(s);
expect(p.l).toBeCloseTo(0.732, 3);
expect(p.c).toBeCloseTo(0.123, 3);
expect(p.h).toBe(275);
});
});
50 changes: 50 additions & 0 deletions configurator/tests/domains.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Unit tests for src/lib/domains.ts — the runtime that routes each token to the
* panel (domain) it appears under. Previously only exercised indirectly via the
* curation CLI. `domainOf` is the runtime side of the same contract
* check:curation guards, so it deserves its own direct lock.
*
* Why this matters: `domainOf` and check-curation's classifier must agree — if
* they drift, a knob can pass CI yet land in the wrong panel (or a phantom one).
*/
import { describe, test, expect } from 'vitest';
import { domainOf, DOMAIN_PATTERNS } from '../src/lib/domains';

describe('domainOf', () => {
test.each([
['--sf-color-primary', 'colors'],
['--sf-font-body', 'typography'],
['--sf-space-m', 'spacing'],
['--sf-radius-l', 'borders'],
['--sf-shadow-m', 'shadows'],
['--sf-motion-scale', 'motion'],
['--sf-container-wide', 'layout'],
['--sf-btn-pad', 'components'],
['--sf-blur-m', 'effects'],
])('%s → %s', (name, expected) => {
expect(domainOf(name)).toBe(expected);
});

test('an unrecognised name falls back to "misc"', () => {
expect(domainOf('--sf-totally-unknown-xyz')).toBe('misc');
});

test('a name matching a misc pattern also resolves to "misc"', () => {
// z-index tokens legitimately live in the Misc panel.
expect(domainOf('--sf-z-modal')).toBe('misc');
expect(domainOf('--sf-focus-ring-width')).toBe('misc');
});

test('never returns a domain key that is absent from DOMAIN_PATTERNS', () => {
const keys = new Set(Object.keys(DOMAIN_PATTERNS));
for (const n of ['--sf-color-x', '--sf-unknown', '--sf-z-base', '--sf-btn-y']) {
expect(keys.has(domainOf(n))).toBe(true);
}
});

test('a more specific domain wins over the misc fallback', () => {
// "size-" is a misc pattern, but "--sf-btn-*" should still classify as a
// component if it ever collides — this guards the precedence order.
expect(domainOf('--sf-container-narrow')).toBe('layout');
});
});
69 changes: 69 additions & 0 deletions configurator/tests/powerKnobs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Contract tests for src/lib/powerKnobs.ts — the curated "power knob" scalars
* (contrast bias, text/space/radius scales, shadow strength, motion scale…)
* surfaced as high-leverage sliders.
*
* Why this matters: each knob's {min, default, max, step} is fed straight into
* a range input, and some carry an encode/decode pair that rewrites the value
* into a calc() expression on the way to CSS and back. A default outside its
* own range, a zero/negative step, or a non-round-tripping codec would produce
* a broken or un-resettable control. None of this was covered.
*/
import { describe, test, expect } from 'vitest';
import { KNOBS_BY_DOMAIN } from '../src/lib/powerKnobs';

const ALL = Object.values(KNOBS_BY_DOMAIN).flat();

describe('KNOBS_BY_DOMAIN structure', () => {
test('is non-empty and every knob targets a distinct --sf-* token', () => {
expect(ALL.length).toBeGreaterThan(0);
const names = ALL.map((k) => k.name);
for (const n of names) expect(n).toMatch(/^--sf-[a-z0-9-]+$/);
expect(new Set(names).size).toBe(names.length);
});

test('every knob has a human label and help text', () => {
for (const k of ALL) {
expect(k.label, k.name).toBeTruthy();
expect(k.help, k.name).toBeTruthy();
}
});
});

describe('range invariants', () => {
test.each(ALL.map((k) => [k.name, k] as const))('%s: min ≤ default ≤ max, step > 0', (_n, k) => {
expect(k.min).toBeLessThanOrEqual(k.default);
expect(k.default).toBeLessThanOrEqual(k.max);
expect(k.max).toBeGreaterThan(k.min);
expect(k.step).toBeGreaterThan(0);
// The slider must be able to reach the default from min in whole steps
// (float tolerance) — otherwise "reset to default" lands off-grid.
const stepsFromMin = (k.default - k.min) / k.step;
expect(Math.abs(stepsFromMin - Math.round(stepsFromMin))).toBeLessThan(1e-6);
});
});

describe('encode/decode round-trip (knobs that carry a codec)', () => {
const coded = ALL.filter((k) => typeof k.encode === 'function' && typeof k.decode === 'function');

test('at least one knob exercises the codec path (shadow strength)', () => {
expect(coded.some((k) => k.name === '--sf-shadow-strength')).toBe(true);
});

test.each(coded.map((k) => [k.name, k] as const))(
'%s: decode(encode(v)) recovers v across the range',
(_n, k) => {
for (let v = k.min; v <= k.max + 1e-9; v += k.step) {
const round = Math.round(v * 1000) / 1000;
const encoded = k.encode!(round);
expect(typeof encoded).toBe('string');
expect(k.decode!(encoded)).toBeCloseTo(round, 5);
}
},
);

test('shadow decode returns NaN on an unparseable value (defensive fallback)', () => {
const shadow = coded.find((k) => k.name === '--sf-shadow-strength')!;
expect(Number.isNaN(shadow.decode!('not-a-calc'))).toBe(true);
});
});
66 changes: 66 additions & 0 deletions configurator/tests/theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Unit tests for src/lib/theme.svelte.ts — the Studio chrome light/dark store.
*
* Why this matters: this module is imported by 8 components and owns the
* persisted studio theme + the `dark` class binding Tailwind's `dark:` variant
* keys off. It had no unit coverage — a regression in persistence or the root
* toggle would flip the whole studio's appearance. jsdom provides localStorage;
* matchMedia is intentionally absent, so the module's typeof-guards are also
* exercised here.
*/
import { describe, test, expect, beforeEach } from 'vitest';
import { themeState, setTheme, toggleTheme, forceTheme, bindThemeRoot } from '../src/lib/theme.svelte';

const STORAGE_KEY = 'slashed-studio-theme';

beforeEach(() => {
localStorage.clear();
});

describe('setTheme', () => {
test('updates the reactive state and persists the choice', () => {
setTheme('dark');
expect(themeState.value).toBe('dark');
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark');

setTheme('light');
expect(themeState.value).toBe('light');
expect(localStorage.getItem(STORAGE_KEY)).toBe('light');
});
});

describe('toggleTheme', () => {
test('flips between light and dark', () => {
setTheme('light');
toggleTheme();
expect(themeState.value).toBe('dark');
toggleTheme();
expect(themeState.value).toBe('light');
});
});

describe('bindThemeRoot', () => {
test('applies/removes the `dark` class to reflect the current theme', () => {
const el = document.createElement('div');
setTheme('dark');
bindThemeRoot(el);
expect(el.classList.contains('dark')).toBe(true);

setTheme('light');
expect(el.classList.contains('dark')).toBe(false);

setTheme('dark');
expect(el.classList.contains('dark')).toBe(true);
});
});

describe('forceTheme', () => {
test('sets the in-memory theme WITHOUT persisting it', () => {
localStorage.clear();
forceTheme('dark');
expect(themeState.value).toBe('dark');
// The whole point of forceTheme: no storage write, so an embedding host can
// pin appearance without clobbering the user's own saved preference.
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
});
46 changes: 46 additions & 0 deletions configurator/tests/variableScales.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Unit tests for src/lib/variableScales.ts — the sibling-scale option lists
* SliderRow's variable picker offers when a knob defaults to another token
* (e.g. `--sf-gap: var(--sf-space-m)`).
*
* Why this matters: these lists are consumed as-is to build dropdowns whose
* `value` strings are written straight into the exported CSS. A malformed
* `var(--sf-…)` string, a wrong prefix, or a dropped step would silently emit
* broken CSS. This pins the shape and the exact wire format.
*/
import { describe, test, expect } from 'vitest';
import {
SPACE_SCALE,
RADIUS_SCALE,
BORDER_WIDTH_SCALE,
CONTAINER_SCALE,
SIZE_SCALE,
SHADOW_SCALE,
type VarOption,
} from '../src/lib/variableScales';

const ALL: Record<string, { scale: VarOption[]; prefix: string; steps: string[] }> = {
SPACE_SCALE: { scale: SPACE_SCALE, prefix: 'space', steps: ['2xs', 'xs', 's', 'm', 'l', 'xl', '2xl', '3xl', '4xl'] },
RADIUS_SCALE: { scale: RADIUS_SCALE, prefix: 'radius', steps: ['2xs', 'xs', 's', 'm', 'l', 'xl', '2xl', '3xl', '4xl', 'full'] },
BORDER_WIDTH_SCALE: { scale: BORDER_WIDTH_SCALE, prefix: 'border-width', steps: ['1', '2', '3', '4'] },
CONTAINER_SCALE: { scale: CONTAINER_SCALE, prefix: 'container', steps: ['narrow', 'prose', 'default', 'wide', 'full'] },
SIZE_SCALE: { scale: SIZE_SCALE, prefix: 'size', steps: ['xs', 's', 'm', 'l', 'xl'] },
SHADOW_SCALE: { scale: SHADOW_SCALE, prefix: 'shadow', steps: ['xs', 's', 'm', 'l', 'xl'] },
};

describe.each(Object.entries(ALL))('%s', (_name, { scale, prefix, steps }) => {
test('has one option per documented step, in order', () => {
expect(scale.map((o) => o.label)).toEqual(steps.map((s) => `${prefix}-${s}`));
});

test('every value is a well-formed var(--sf-<prefix>-<step>) reference', () => {
for (const [i, opt] of scale.entries()) {
expect(opt.value).toBe(`var(--sf-${prefix}-${steps[i]})`);
expect(opt.value).toMatch(/^var\(--sf-[a-z0-9-]+\)$/);
}
});

test('labels are unique', () => {
expect(new Set(scale.map((o) => o.label)).size).toBe(scale.length);
});
});
8 changes: 7 additions & 1 deletion scripts/check-llm-guide.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@
import fs from 'node:fs';
import path from 'node:path';

const ROOT = path.resolve(import.meta.dirname, '..');
// SLASHED_ROOT lets negative tests run the gate against a fixture tree. An
// empty/whitespace value counts as unset; a relative override is resolved to
// absolute so path.join below never targets the process CWD.
const slashedRoot = process.env.SLASHED_ROOT?.trim();
const ROOT = slashedRoot
? path.resolve(slashedRoot)
: path.resolve(import.meta.dirname, '..');
const GUIDE = path.join(ROOT, 'docs', 'llm-guide.md');
const REGISTRY = path.join(ROOT, 'token-registry.json');
const API_INDEX = path.join(ROOT, 'docs', 'api-index.json');
Expand Down
Loading