aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/test-colormath.mjs
blob: 2a58ad610d4f7ebac43d979248c1f2678c3eac01 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
// Unit tests for the pure color-math core (colormath.js).
// Run: node --test scripts/theme-studio/
// Run with coverage: node --test --experimental-test-coverage scripts/theme-studio/
//
// Fixtures are from the perceptual-color-metrics spec: OKLab via Ottosson's
// reference, APCA via APCA-W3 0.1.9, deltaE via OKLab Euclidean distance.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
  srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE,
  hex2rgb, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex,
  oklab2lrgb, inGamut, lrgb2hex,
} from './colormath.js';

const close = (a, b, eps = 0.005) => Math.abs(a - b) <= eps;
const here = fileURLToPath(new URL('.', import.meta.url));
// Same export-strip generate.py applies before inlining (drop `export` lines, rstrip).
const stripExports = (s) =>
  s.split('\n').filter((l) => !l.startsWith('export')).join('\n').replace(/\s+$/, '');

test('srgb2oklab achromatic anchors', () => {
  const w = srgb2oklab('#ffffff');
  assert.ok(close(w.L, 1.0), `white L ${w.L}`);
  assert.ok(close(w.a, 0) && close(w.b, 0), `white a/b ${w.a},${w.b}`);
  const k = srgb2oklab('#000000');
  assert.ok(close(k.L, 0), `black L ${k.L}`);
});

test('OKLCH chromatic fixtures (red, dupre-blue)', () => {
  const red = oklab2oklch(srgb2oklab('#ff0000'));
  assert.ok(close(red.L, 0.628) && close(red.C, 0.258) && close(red.H, 29.2, 1),
    `red ${JSON.stringify(red)}`);
  const blue = oklab2oklch(srgb2oklab('#67809c'));
  assert.ok(close(blue.L, 0.591) && close(blue.C, 0.052) && close(blue.H, 251.6, 1),
    `dupre-blue ${JSON.stringify(blue)}`);
});

test('round-trip srgb -> oklch -> hex preserves the color', () => {
  for (const h of ['#67809c', '#e8bd30', '#9b5fd0', '#5d9b86', '#cb6b4d']) {
    const lab = srgb2oklab(h);
    const c = oklab2oklch(lab);
    const back = srgb2oklab(oklch2hex(c.L, c.C, c.H).hex);
    assert.ok(close(lab.L, back.L) && close(lab.a, back.a) && close(lab.b, back.b),
      `roundtrip ${h}`);
  }
});

test('APCA both polarities (pinned black/white fixtures)', () => {
  assert.ok(close(apca('#000000', '#ffffff'), 106.0, 0.5),
    `dark-on-light ${apca('#000000', '#ffffff')}`);
  assert.ok(close(apca('#ffffff', '#000000'), -107.9, 0.5),
    `light-on-dark ${apca('#ffffff', '#000000')}`);
  // Chromatic fixture: catches rounded-coefficient drift that black/white can't.
  // Sign is positive (dark-ish text on a light bg).
  assert.ok(apca('#67809c', '#ffffff') > 0, 'chromatic apca sign');
});

test('deltaE-OK identity and ordering against the 0.02 threshold', () => {
  assert.equal(deltaE('#67809c', '#67809c'), 0);
  assert.ok(deltaE('#000000', '#ffffff') > 0);
  const near = deltaE('#67809c', '#69829e'); // barely-different blue
  const far = deltaE('#67809c', '#e8bd30');  // blue vs gold
  assert.ok(near < 0.02, `near ${near}`);
  assert.ok(far > 0.1, `far ${far}`);
});

test('gamut clamp preserves L/H, reduces C, flags clamped', () => {
  const oog = oklch2hex(0.6, 0.5, 30); // very high chroma -> out of sRGB
  assert.equal(oog.clamped, true);
  const got = oklab2oklch(srgb2oklab(oog.hex));
  assert.ok(close(got.L, 0.6, 0.02), `L preserved ${got.L}`);
  assert.ok(close(got.H, 30, 2), `H preserved ${got.H}`);
  assert.ok(got.C < 0.5, `C reduced ${got.C}`);
  const ing = oklch2hex(0.591, 0.052, 251.6); // in gamut
  assert.equal(ing.clamped, false);
});

test('hex2rgb parses channels', () => {
  assert.deepEqual(hex2rgb('#000000'), [0, 0, 0]);
  assert.deepEqual(hex2rgb('#ffffff'), [255, 255, 255]);
  assert.deepEqual(hex2rgb('#67809c'), [0x67, 0x80, 0x9c]);
});

test('WCAG relative luminance anchors', () => {
  assert.ok(close(rl('#ffffff'), 1.0, 1e-9), `white ${rl('#ffffff')}`);
  assert.ok(close(rl('#000000'), 0.0, 1e-9), `black ${rl('#000000')}`);
  assert.ok(rl('#ff0000') < rl('#00ff00'), 'green brighter than red'); // 0.2126 vs 0.7152
});

test('WCAG contrast: symmetry, identity, and known extremes', () => {
  assert.ok(close(contrast('#000000', '#ffffff'), 21, 1e-6), 'black/white = 21:1');
  assert.equal(contrast('#67809c', '#67809c'), 1); // identical colors
  assert.ok(close(contrast('#0d0b0a', '#67809c'), contrast('#67809c', '#0d0b0a'), 1e-12),
    'order-independent');
  // dupre keyword-blue on ground, a real palette pair (sanity, not a hand-typed number).
  assert.ok(contrast('#67809c', '#0d0b0a') > 4.5, 'dupre blue clears AA on ground');
});

test('rating bands at the AA/AAA boundaries', () => {
  assert.equal(rating(7.0), 'AAA');
  assert.equal(rating(6.99), 'AA');
  assert.equal(rating(4.5), 'AA');
  assert.equal(rating(4.49), 'FAIL');
  assert.equal(rating(0), 'FAIL');
});

test('hsv2rgb primaries and achromatic edges', () => {
  assert.deepEqual(hsv2rgb(0, 1, 1), [255, 0, 0]);
  assert.deepEqual(hsv2rgb(120, 1, 1), [0, 255, 0]);
  assert.deepEqual(hsv2rgb(240, 1, 1), [0, 0, 255]);
  assert.deepEqual(hsv2rgb(0, 0, 1), [255, 255, 255]); // s=0 -> grey (white)
  assert.deepEqual(hsv2rgb(0, 0, 0), [0, 0, 0]);        // v=0 -> black
  assert.deepEqual(hsv2rgb(360, 1, 1), [255, 0, 0]);    // hue wraps
});

test('rgb2hsv inverts hsv2rgb (saturation/value), hue for chromatic inputs', () => {
  assert.deepEqual(rgb2hsv(255, 0, 0), [0, 1, 1]);
  assert.deepEqual(rgb2hsv(0, 0, 0), [0, 0, 0]); // black: h and s undefined -> 0
  const [h, s, v] = rgb2hsv(0, 255, 0);
  assert.ok(close(h, 120, 1e-9) && s === 1 && v === 1, `green hsv ${h},${s},${v}`);
});

test('hsv <-> rgb round-trip property over random colors', () => {
  let seed = 1234567; // deterministic LCG: no Math.random, repeatable failures
  const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
  for (let i = 0; i < 500; i++) {
    const rgb = [Math.floor(rnd() * 256), Math.floor(rnd() * 256), Math.floor(rnd() * 256)];
    const [h, s, v] = rgb2hsv(...rgb);
    assert.deepEqual(hsv2rgb(h, s, v), rgb, `round-trip ${rgb}`);
  }
});

test('rgb2hex formats and clamps out-of-range channels', () => {
  assert.equal(rgb2hex(0, 0, 0), '#000000');
  assert.equal(rgb2hex(255, 255, 255), '#ffffff');
  assert.equal(rgb2hex(0x67, 0x80, 0x9c), '#67809c');
  assert.equal(rgb2hex(-5, 300, 128), '#00ff80'); // clamps below 0 and above 255
});

test('oklab2lrgb / lrgb2hex round-trip through known sRGB colors', () => {
  for (const h of ['#000000', '#ffffff', '#67809c', '#e8bd30']) {
    const lab = srgb2oklab(h);
    assert.equal(lrgb2hex(oklab2lrgb(lab.L, lab.a, lab.b)), h, `round-trip ${h}`);
  }
});

test('inGamut flags reachable vs unreachable OKLCH (forward-only gamut test)', () => {
  // dupre-blue is a real sRGB color -> in gamut.
  const ok = oklch2oklab(0.591, 0.052, 251.6);
  assert.equal(inGamut(oklab2lrgb(ok.L, ok.a, ok.b)), true, 'reachable');
  // very high chroma at mid lightness -> outside sRGB.
  const bad = oklch2oklab(0.7, 0.4, 140);
  assert.equal(inGamut(oklab2lrgb(bad.L, bad.a, bad.b)), false, 'unreachable');
  // the in-gamut verdict must agree with oklch2hex's clamped flag (the plane and
  // the commit path share one gamut boundary).
  assert.equal(inGamut(oklab2lrgb(ok.L, ok.a, ok.b)), !oklch2hex(0.591, 0.052, 251.6).clamped);
  assert.equal(inGamut(oklab2lrgb(bad.L, bad.a, bad.b)), !oklch2hex(0.7, 0.4, 140).clamped);
});

// Guards the one-source-of-truth contract: the page must carry colormath.js's
// body (sans exports) verbatim, so the inlined copy and the tested module cannot
// drift. Requires `python3 generate.py` to have run first.
test('inline-integrity: theme-studio.html contains the colormath.js body verbatim', () => {
  const body = stripExports(readFileSync(here + 'colormath.js', 'utf8'));
  const html = readFileSync(here + 'theme-studio.html', 'utf8');
  assert.ok(html.includes(body), 'generated page is missing the colormath.js body verbatim');
});