aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/test-colormath.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/theme-studio/test-colormath.mjs')
-rw-r--r--scripts/theme-studio/test-colormath.mjs161
1 files changed, 156 insertions, 5 deletions
diff --git a/scripts/theme-studio/test-colormath.mjs b/scripts/theme-studio/test-colormath.mjs
index 2e929b7b5..a1ec9264e 100644
--- a/scripts/theme-studio/test-colormath.mjs
+++ b/scripts/theme-studio/test-colormath.mjs
@@ -10,15 +10,16 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
- srgb2oklab, oklab2oklch, oklch2hex, apca, deltaE,
+ srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE,
hex2rgb, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex,
+ oklab2lrgb, inGamut, lrgb2hex, planeCell, paletteWarnings,
+ reliefColors, isPureEndpointHex,
} 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+$/, '');
+// Same strip generate.py applies before inlining (drop export/import lines, rstrip).
+import { stripInlinedBody } from './inline-strip.mjs';
test('srgb2oklab achromatic anchors', () => {
const w = srgb2oklab('#ffffff');
@@ -139,11 +140,161 @@ test('rgb2hex formats and clamps out-of-range channels', () => {
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);
+});
+
+test('planeCell: reachable cell returns its exact hex, agrees with oklch2hex', () => {
+ // Normal: a low-chroma blue is reachable; the hex matches the commit path.
+ const cell = planeCell(0.591, 0.052, 251.6);
+ assert.equal(cell.inGamut, true);
+ assert.equal(cell.hex, oklch2hex(0.591, 0.052, 251.6).hex);
+});
+
+test('planeCell: C=0 is the achromatic grey for its lightness', () => {
+ // Boundary: zero chroma -> a neutral grey, always in gamut, hue irrelevant.
+ const a = planeCell(0.5, 0, 0), b = planeCell(0.5, 0, 251.6);
+ assert.equal(a.inGamut, true);
+ assert.equal(a.hex, b.hex, 'hue must not matter at C=0');
+ assert.equal(a.hex[1], a.hex[3]); // r==g==b nibble: grey
+});
+
+test('planeCell: out-of-gamut chroma is flagged, no hex', () => {
+ // Error/boundary: chroma past sRGB at this L/H.
+ const cell = planeCell(0.7, 0.4, 140);
+ assert.equal(cell.inGamut, false);
+ assert.equal(cell.hex, null);
+ assert.equal(cell.inGamut, !oklch2hex(0.7, 0.4, 140).clamped); // shares the boundary
+});
+
+test('paletteWarnings: a near-identical pair warns, named, with its ΔE', () => {
+ const { warnings, overflow, nearest } = paletteWarnings(
+ [['#0d0b0a', 'ground'], ['#cdced1', 'fg'], ['#67809c', 'blue'], ['#69829e', 'blue2']]);
+ assert.equal(warnings.length, 1);
+ assert.equal(overflow, 0);
+ const w = warnings[0];
+ assert.deepEqual([w.aName, w.bName], ['blue', 'blue2']);
+ assert.ok(w.dE > 0 && w.dE < 0.02, `dE ${w.dE}`);
+ assert.equal(nearest.length, 4);
+ assert.ok(nearest[2] < 0.02 && nearest[3] < 0.02, 'blue/blue2 are each other’s nearest');
+});
+
+test('paletteWarnings: a well-spread palette warns about nothing', () => {
+ const { warnings, overflow } = paletteWarnings(
+ [['#0d0b0a', 'ground'], ['#cdced1', 'fg'], ['#67809c', 'blue'], ['#e8bd30', 'gold'], ['#cb6b4d', 'terra']]);
+ assert.equal(warnings.length, 0);
+ assert.equal(overflow, 0);
+});
+
+test('paletteWarnings: boundary cases — empty, single, identical', () => {
+ assert.deepEqual(paletteWarnings([]), { warnings: [], overflow: 0, nearest: [] });
+ const one = paletteWarnings([['#67809c', 'blue']]);
+ assert.deepEqual(one.warnings, []);
+ assert.deepEqual(one.nearest, [Infinity]); // no neighbor
+ const dup = paletteWarnings([['#67809c', 'a'], ['#67809c', 'b']]);
+ assert.equal(dup.warnings.length, 1);
+ assert.equal(dup.warnings[0].dE, 0); // identical colors -> ΔE 0
+});
+
+test('paletteWarnings: closest-first ordering and cap with overflow', () => {
+ // Seven near-identical colors -> C(7,2)=21 sub-threshold pairs.
+ const pal = [['#0d0b0a', 'ground'], ['#cdced1', 'fg']];
+ for (let k = 0; k < 7; k++) pal.push(['#' + (0x67 + k).toString(16).padStart(2, '0') + '809c', 'c' + k]);
+ const { warnings, overflow } = paletteWarnings(pal, 0.02, 5);
+ assert.equal(warnings.length, 5, 'capped at 5');
+ assert.equal(overflow, 16, '21 pairs - 5 shown');
+ for (let i = 1; i < warnings.length; i++)
+ assert.ok(warnings[i].dE >= warnings[i - 1].dE, 'ascending by ΔE');
+});
+
+test('paletteWarnings: threshold is inclusive-exclusive at the boundary', () => {
+ // A custom threshold lets a pair fall just inside or just outside.
+ const pal = [['#67809c', 'a'], ['#69829e', 'b']]; // dE ~0.0067
+ assert.equal(paletteWarnings(pal, 0.0067).warnings.length, 0, 'd < threshold is strict');
+ assert.equal(paletteWarnings(pal, 0.007).warnings.length, 1, 'just above the pair distance');
+});
+
+// Fixtures hand-computed from Emacs 30's xterm.c x_alloc_lighter_color
+// (factor 1.2 / delta 0x8000 highlight, 0.6 / 0x4000 shadow, dark boost
+// below brightness 48000/65535, same-color fallback adds delta).
+test('reliefColors: dark mode-line bg gets the dark boost (Normal)', () => {
+ const { hl, sh } = reliefColors('#30343c');
+ assert.equal(hl, '#71767f');
+ assert.equal(sh, '#0f1116');
+});
+
+test('reliefColors: grey75 brightness is above the boost limit (Normal)', () => {
+ const { hl, sh } = reliefColors('#bfbfbf');
+ assert.equal(hl, '#e5e5e5'); // 1.2x only, no additive boost
+ assert.equal(sh, '#737373'); // 0.6x only
+});
+
+test('reliefColors: pure black hits the same-color fallback for the shadow (Boundary)', () => {
+ const { hl, sh } = reliefColors('#000000');
+ assert.equal(hl, '#4d4d4d'); // boost lifts the highlight off black
+ assert.equal(sh, '#404040'); // 0.6x + boost still black -> fallback adds delta
+});
+
+test('reliefColors: pure white highlight saturates, shadow scales (Boundary)', () => {
+ const { hl, sh } = reliefColors('#ffffff');
+ assert.equal(hl, '#ffffff'); // clamped, fallback also clamps to white
+ assert.equal(sh, '#999999');
+});
+
+test('reliefColors: malformed hex returns null pair (Error)', () => {
+ assert.deepEqual(reliefColors('nonsense'), { hl: null, sh: null });
+});
+
// 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 body = stripInlinedBody(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');
});
+
+// --- apca contrast branches + isPureEndpointHex ------------------------------
+
+test('apca: Boundary — equal luminance returns 0 (below the delta-Y floor)', () => {
+ assert.equal(apca('#808080', '#808080'), 0);
+});
+test('apca: Normal — dark text on light background is positive (Ybg > Ytxt)', () => {
+ assert.ok(apca('#000000', '#ffffff') > 0);
+});
+test('apca: Normal — light text on dark background is negative (else branch)', () => {
+ assert.ok(apca('#ffffff', '#000000') < 0);
+});
+test('apca: Boundary — near-equal colors below the floor clamp to 0', () => {
+ assert.equal(apca('#808080', '#828282'), 0);
+});
+
+test('isPureEndpointHex: Normal — pure black and white are endpoints', () => {
+ assert.equal(isPureEndpointHex('#ffffff'), true);
+ assert.equal(isPureEndpointHex('#000000'), true);
+ assert.equal(isPureEndpointHex('#FFFFFF'), true);
+});
+test('isPureEndpointHex: Boundary — any other color is not an endpoint', () => {
+ assert.equal(isPureEndpointHex('#010101'), false);
+ assert.equal(isPureEndpointHex('#123456'), false);
+});
+test('isPureEndpointHex: Error — null/empty is not an endpoint', () => {
+ assert.equal(isPureEndpointHex(null), false);
+ assert.equal(isPureEndpointHex(''), false);
+});