diff options
Diffstat (limited to 'scripts/theme-studio/test-contrast.mjs')
| -rw-r--r-- | scripts/theme-studio/test-contrast.mjs | 111 |
1 files changed, 111 insertions, 0 deletions
diff --git a/scripts/theme-studio/test-contrast.mjs b/scripts/theme-studio/test-contrast.mjs new file mode 100644 index 00000000..9baf5bcc --- /dev/null +++ b/scripts/theme-studio/test-contrast.mjs @@ -0,0 +1,111 @@ +// Unit tests for the background-contrast safety core (app-core.js): fgSetFor, +// floor, and lMax. Phase 3 of the palette-ramps spec. A background overlay sits +// behind many foregrounds at once, so its real constraint is the worst-case +// (minimum) contrast over that foreground set, and the lightest background that +// keeps the floor above the target. Pure, no DOM. Run: node --test scripts/theme-studio/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fgSetFor, floor, lMax, COVERED_FACES } from './app-core.js'; +import { contrast, oklch2hex } from './colormath.js'; + +const DEFAULT_FG = '#f0fef0'; +const stateWith = (syntaxAssignments) => ({ covered: COVERED_FACES, syntaxAssignments, defaultFg: DEFAULT_FG }); + +// --- fgSetFor --------------------------------------------------------------- + +test('fgSetFor: Normal — covered face gets default fg plus the distinct syntax colors', () => { + const r = fgSetFor('region', stateWith([ + { role: 'keyword', hex: '#67809c' }, + { role: 'string', hex: '#a3b18a' }, + ])); + assert.equal(r.reason, undefined); + assert.equal(r.set.length, 3); // default + 2 syntax + const hexes = r.set.map(e => e.hex); + assert.ok(hexes.includes('#f0fef0') && hexes.includes('#67809c') && hexes.includes('#a3b18a')); + assert.equal(r.set.find(e => e.hex === '#67809c').label, 'keyword'); + assert.equal(r.set.find(e => e.hex === '#f0fef0').label, 'default'); +}); + +test('fgSetFor: Boundary — a syntax hex equal to the default collapses, role label wins', () => { + const r = fgSetFor('region', { covered: COVERED_FACES, syntaxAssignments: [{ role: 'keyword', hex: '#f0fef0' }], defaultFg: '#f0fef0' }); + assert.equal(r.set.length, 1); + assert.equal(r.set[0].label, 'keyword'); // role preferred over 'default' +}); + +test('fgSetFor: Boundary — null/blank syntax hexes are dropped', () => { + const r = fgSetFor('isearch', stateWith([{ role: 'a', hex: null }, { role: 'b', hex: '#112233' }])); + assert.equal(r.set.length, 2); // default + the one real hex + assert.ok(r.set.some(e => e.hex === '#112233')); +}); + +test('fgSetFor: Error — a face outside the covered set is out-of-scope', () => { + const r = fgSetFor('mode-line', stateWith([{ role: 'keyword', hex: '#67809c' }])); + assert.deepEqual(r, { set: [], reason: 'out-of-scope' }); +}); + +test('fgSetFor: Error — a covered face with no syntax assignments is empty', () => { + const r = fgSetFor('hl-line', stateWith([])); + assert.deepEqual(r, { set: [], reason: 'empty' }); +}); + +test('fgSetFor: Normal — every covered face is in scope', () => { + for (const f of COVERED_FACES) { + const r = fgSetFor(f, stateWith([{ role: 'kw', hex: '#67809c' }])); + assert.equal(r.reason, undefined, `${f} should be covered`); + } +}); + +// --- floor ------------------------------------------------------------------ + +test('floor: Normal — the keyword-blue worst case sets the floor and is named', () => { + // sterling's keyword blue is the darkest foreground; against a lifted highlight + // background it is the limiting color while the light default still clears. + const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }]; + const bg = '#202830'; + const r = floor(bg, fgSet); + assert.equal(r.limitingHex, '#67809c'); + assert.equal(r.limitingLabel, 'keyword'); + assert.ok(Math.abs(r.ratio - contrast('#67809c', bg)) < 1e-9); + assert.ok(r.ratio < contrast('#f0fef0', bg), 'the floor is below the default-fg contrast'); +}); + +test('floor: Boundary — a single-entry set makes that entry the limit', () => { + const r = floor('#000000', [{ hex: '#67809c', label: 'keyword' }]); + assert.equal(r.limitingHex, '#67809c'); + assert.ok(Math.abs(r.ratio - contrast('#67809c', '#000000')) < 1e-9); +}); + +test('floor: Error — an empty set returns nulls, not a bogus ratio', () => { + assert.deepEqual(floor('#000000', []), { ratio: null, limitingHex: null, limitingLabel: null }); +}); + +// --- lMax ------------------------------------------------------------------- + +const F = (L, chroma, hue, fgSet) => floor(oklch2hex(L, chroma, hue).hex, fgSet).ratio; + +test('lMax: Normal — finds the lightest safe background; the floor brackets the target', () => { + const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }]; + const r = lMax(0, 0, fgSet, 4.5); + assert.equal(r.status, 'ok'); + assert.ok(r.L > 0 && r.L < 1); + assert.ok(F(r.L, 0, 0, fgSet) >= 4.5 - 0.05, 'floor at L_max clears the target'); + assert.ok(F(Math.min(1, r.L + 0.05), 0, 0, fgSet) < 4.5, 'just above L_max the floor fails'); +}); + +test('lMax: Boundary — no L satisfies the target when a foreground is too dark', () => { + const r = lMax(0, 0, [{ hex: '#1a1a1a', label: 'dim' }], 4.5); + assert.equal(r.status, 'none'); // even pure-black background can't lift #1a1a1a to AA +}); + +test('lMax: Boundary — an empty foreground set is vacuously safe everywhere', () => { + const r = lMax(0, 0, [], 4.5); + assert.deepEqual(r, { L: 1, status: 'all' }); +}); + +test('lMax: Boundary — requesting an unreachable chroma at the ceiling reports clamp', () => { + const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }]; + const r = lMax(250, 0.3, fgSet, 4.5); // 0.3 chroma is out of gamut at the dark ceiling + assert.equal(r.status, 'clamp'); + assert.ok(r.L > 0 && r.L < 1); +}); |
