diff options
Diffstat (limited to 'scripts/theme-studio/test-app-core.mjs')
| -rw-r--r-- | scripts/theme-studio/test-app-core.mjs | 332 |
1 files changed, 332 insertions, 0 deletions
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs new file mode 100644 index 00000000..ded3da94 --- /dev/null +++ b/scripts/theme-studio/test-app-core.mjs @@ -0,0 +1,332 @@ +// Unit tests for the pure app logic (app-core.js): the package-face model and +// the dropdown option list. These are the functions Stage 7 made importable. +// Run: node --test scripts/theme-studio/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { + nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, spanNeighborHex, slugify, + clearPalettePlan, deletePaletteColumnPlan, groundColumnMembersFromPalette, areAllLocked, lockToggleLabel, toggleLockSet, +} from './app-core.js'; + +const here = fileURLToPath(new URL('.', import.meta.url)); +const PAL = [['#67809c', 'blue'], ['#e8bd30', 'gold']]; + +test('nameToHex: Normal — resolves a palette name to its hex', () => { + assert.equal(nameToHex('blue', PAL), '#67809c'); + assert.equal(nameToHex('gold', PAL), '#e8bd30'); +}); + +test('nameToHex: Normal — a raw #hex passes through unchanged', () => { + assert.equal(nameToHex('#abcdef', PAL), '#abcdef'); +}); + +test('nameToHex: Boundary/Error — null, empty, and unknown names give null', () => { + assert.equal(nameToHex(null, PAL), null); + assert.equal(nameToHex('', PAL), null); + assert.equal(nameToHex(undefined, PAL), null); + assert.equal(nameToHex('chartreuse', PAL), null); +}); + +test('optList: Normal — default entry then the whole palette', () => { + assert.deepEqual(optList('#67809c', PAL), [['', '— default —'], ...PAL]); +}); + +test('optList: Boundary — empty cur is "have", so no (gone) entry', () => { + assert.deepEqual(optList('', PAL), [['', '— default —'], ...PAL]); +}); + +test('optList: Error — a cur not in the palette is surfaced as (gone) first', () => { + const list = optList('#123456', PAL); + assert.deepEqual(list[0], ['', '— default —']); + assert.deepEqual(list[1], ['#123456', '(gone) #123456']); + assert.deepEqual(list.slice(2), PAL); +}); + +test('paletteOptionList: Normal — color choices follow visual column ordering', () => { + const pal = [ + ['#67809c', 'blue'], + ['#0d0b0a', 'bg'], + ['#808080', 'gray'], + ['#c0402a', 'red'], + ['#f0fef0', 'fg'], + ]; + const list = paletteOptionList('#67809c', pal, { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.deepEqual(list.slice(0, 3), [['', '— default —'], ['#0d0b0a', 'bg'], ['#f0fef0', 'fg']]); + assert.ok(list.findIndex(([, name]) => name === 'blue') < list.findIndex(([, name]) => name === 'gray'), 'palette column order is preserved'); + assert.ok(list.findIndex(([, name]) => name === 'gray') < list.findIndex(([, name]) => name === 'red'), 'later columns stay later'); +}); + +test('paletteOptionList: Normal — colors within each column are lightest to darkest', () => { + const pal = [ + ['#111111', 'bg', 'ground'], + ['#eeeeee', 'fg', 'ground'], + ['#444444', 'gray-dark', 'gray'], + ['#cccccc', 'gray-light', 'gray'], + ['#888888', 'gray-mid', 'gray'], + ['#330000', 'red-dark', 'red'], + ['#dd8888', 'red-light', 'red'], + ]; + const list = paletteOptionList('', pal, { bg: '#111111', fg: '#eeeeee' }); + assert.deepEqual(list.slice(0, 3).map(([, name]) => name), ['— default —', 'bg', 'fg']); + assert.deepEqual( + list.filter(([, name]) => name.startsWith('gray')).map(([, name]) => name), + ['gray-light', 'gray-mid', 'gray-dark'], + ); + assert.ok(list.findIndex(([, name]) => name === 'gray-dark') < list.findIndex(([, name]) => name === 'red-light'), 'column order is still left-to-right'); + assert.deepEqual( + list.filter(([, name]) => name.startsWith('red')).map(([, name]) => name), + ['red-light', 'red-dark'], + ); +}); + +test('paletteOptionList: Boundary — assignment-only ground colors are selectable', () => { + const list = paletteOptionList('', [['#67809c', 'blue']], { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.ok(list.some(([hex, name]) => hex === '#0d0b0a' && name === 'bg')); + assert.ok(list.some(([hex, name]) => hex === '#f0fef0' && name === 'fg')); +}); + +test('paletteOptionList: Boundary — bg-like imported colors remain selectable outside ground', () => { + const pal = [['#0d0b0a', 'bg2'], ['#0d0b0a', 'bg', 'ground'], ['#f0fef0', 'fg', 'ground']]; + const list = paletteOptionList('', pal, { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.deepEqual(list.slice(0, 4), [['', '— default —'], ['#0d0b0a', 'bg'], ['#f0fef0', 'fg'], ['#0d0b0a', 'bg2']]); +}); + +test('paletteOptionList: Error — a cur outside palette and ground is surfaced as gone', () => { + const list = paletteOptionList('#123456', PAL, { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.deepEqual(list[0], ['', '— default —']); + assert.deepEqual(list[1], ['#123456', '(gone) #123456']); +}); + +test('spanNeighborHex: Normal — steps lighter and darker within the current column', () => { + const pal = [ + ['#222222', 'gray-dark', 'gray'], + ['#888888', 'gray-mid', 'gray'], + ['#dddddd', 'gray-light', 'gray'], + ['#330000', 'red-dark', 'red'], + ]; + const ground = { bg: '#000000', fg: '#ffffff' }; + assert.equal(spanNeighborHex('#888888', pal, ground, 1), '#dddddd'); + assert.equal(spanNeighborHex('#888888', pal, ground, -1), '#222222'); + assert.equal(spanNeighborHex('#dddddd', pal, ground, 1), null); + assert.equal(spanNeighborHex('#222222', pal, ground, -1), null); +}); + +test('spanNeighborHex: Normal — ground steps by lightness too', () => { + const pal = [ + ['#ffffff', 'bg', 'ground'], + ['#777777', 'ground+1', 'ground'], + ['#000000', 'fg', 'ground'], + ]; + const ground = { bg: '#ffffff', fg: '#000000' }; + assert.equal(spanNeighborHex('#777777', pal, ground, 1), '#ffffff'); + assert.equal(spanNeighborHex('#777777', pal, ground, -1), '#000000'); +}); + +test('spanNeighborHex: Boundary — default and gone colors cannot step', () => { + assert.equal(spanNeighborHex('', PAL, { bg: '#000000', fg: '#ffffff' }, 1), null); + assert.equal(spanNeighborHex('#123456', PAL, { bg: '#000000', fg: '#ffffff' }, 1), null); +}); + +test('clearPalettePlan: Normal — removes non-ground colors and records recoverable names', () => { + const plan = clearPalettePlan([ + ['#0d0b0a', 'bg', 'ground'], + ['#f0fef0', 'fg', 'ground'], + ['#67809c', 'blue', 'blue'], + ['#92acc2', 'blue+1', 'blue'], + ], { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.deepEqual(plan.palette, [['#0d0b0a', 'bg', 'ground'], ['#f0fef0', 'fg', 'ground']]); + assert.deepEqual(plan.removed, [{ hex: '#67809c', name: 'blue' }, { hex: '#92acc2', name: 'blue+1' }]); +}); + +test('clearPalettePlan: Boundary — synthesizes missing bg and fg endpoints', () => { + const plan = clearPalettePlan([['#67809c', 'blue', 'blue']], { bg: '#000000', fg: '#ffffff' }); + assert.deepEqual(plan.palette, [['#000000', 'bg', 'ground'], ['#ffffff', 'fg', 'ground']]); + assert.deepEqual(plan.removed, [{ hex: '#67809c', name: 'blue' }]); +}); + +test('clearPalettePlan: Boundary — same-hex imported colors are not ground endpoints', () => { + const plan = clearPalettePlan([ + ['#0d0b0a', 'bg2', 'bg2'], + ['#0d0b0a', 'bg', 'ground'], + ['#f0fef0', 'fg', 'ground'], + ], { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.deepEqual(plan.palette, [['#0d0b0a', 'bg', 'ground'], ['#f0fef0', 'fg', 'ground']]); + assert.deepEqual(plan.removed, [{ hex: '#0d0b0a', name: 'bg2' }]); +}); + +test('deletePaletteColumnPlan: Normal — removes one stable column and keeps ground plus neighbors', () => { + const plan = deletePaletteColumnPlan([ + ['#0d0b0a', 'bg', 'ground'], + ['#f0fef0', 'fg', 'ground'], + ['#c0402a', 'red', 'red'], + ['#3a6ea5', 'blue', 'blue'], + ['#92acc2', 'blue+1', 'blue'], + ['#808080', 'gray', 'gray'], + ], { bg: '#0d0b0a', fg: '#f0fef0' }, 'blue'); + assert.deepEqual(plan.palette.map(p => p[1]), ['bg', 'fg', 'red', 'gray']); + assert.deepEqual(plan.removed, [{ hex: '#3a6ea5', name: 'blue' }, { hex: '#92acc2', name: 'blue+1' }]); +}); + +test('deletePaletteColumnPlan: Boundary — never deletes ground entries', () => { + const plan = deletePaletteColumnPlan([ + ['#0d0b0a', 'bg', 'ground'], + ['#555555', 'ground+1', 'ground'], + ['#f0fef0', 'fg', 'ground'], + ], { bg: '#0d0b0a', fg: '#f0fef0' }, 'ground'); + assert.deepEqual(plan.palette.map(p => p[1]), ['bg', 'ground+1', 'fg']); + assert.deepEqual(plan.removed, []); +}); + +test('groundColumnMembersFromPalette: Normal — sorts bg, ground+N steps, then fg', () => { + const members = groundColumnMembersFromPalette([ + ['#ffffff', 'bg', 'ground'], + ['#333333', 'ground+2', 'ground'], + ['#bbbbbb', 'ground+1', 'ground'], + ['#000000', 'fg', 'ground'], + ], { bg: '#ffffff', fg: '#000000' }); + assert.deepEqual(members.map(m => m.name), ['bg', 'ground+1', 'ground+2', 'fg']); +}); + +test('lock helpers: Normal — label and toggle operate on the full key set', () => { + const keys = ['a', 'b', 'c']; + assert.equal(areAllLocked(keys, new Set(['a', 'b'])), false); + assert.equal(lockToggleLabel(keys, new Set(['a', 'b'])), 'lock all'); + const locked = toggleLockSet(keys, new Set(['a'])); + assert.deepEqual([...locked].sort(), keys); + assert.equal(lockToggleLabel(keys, locked), 'unlock all'); + assert.deepEqual([...toggleLockSet(keys, locked)].sort(), []); +}); + +test('buildPkgmap: Normal — seeds faces, resolving names and applying defaults', () => { + const apps = { 'org-mode': { faces: [ + ['org-todo', 'todo', { fg: 'blue', bold: true }], + ['org-done', 'done', { inherit: 'org-todo' }], + ] } }; + const m = buildPkgmap(apps, PAL); + assert.equal(m['org-mode']['org-todo'].fg, '#67809c'); + assert.equal(m['org-mode']['org-todo'].bold, true); + assert.equal(m['org-mode']['org-todo'].source, 'default'); + assert.equal(m['org-mode']['org-todo'].height, 1); + assert.equal(m['org-mode']['org-done'].inherit, 'org-todo'); + assert.equal(m['org-mode']['org-done'].fg, null); +}); + +test('normalizePkgFace: Normal — fills every package face field', () => { + assert.deepEqual(normalizePkgFace({ fg: 'blue', bold: true, inherit: 'base' }, 'default', PAL), { + fg: '#67809c', bg: null, bold: true, italic: false, underline: false, + strike: false, inherit: 'base', height: 1, box: null, source: 'default', + }); +}); + +test('buildPkgmap: Boundary — a face with no default dict still seeds blank', () => { + const m = buildPkgmap({ a: { faces: [['f', 'f']] } }, PAL); + assert.deepEqual(m.a.f, { + fg: null, bg: null, bold: false, italic: false, underline: false, + strike: false, inherit: null, height: 1, box: null, source: 'default', + }); +}); + +test('effResolve: Normal — a face with a value returns it', () => { + const m = { a: { f: { fg: '#67809c', inherit: null } } }; + assert.equal(effResolve(m, 'a', 'f', 'fg'), '#67809c'); +}); + +test('effResolve: Normal — follows the inherit chain when unset', () => { + const m = { a: { + base: { bg: '#0d0b0a', inherit: null }, + mid: { bg: null, inherit: 'base' }, + leaf: { bg: null, inherit: 'mid' }, + } }; + assert.equal(effResolve(m, 'a', 'leaf', 'bg'), '#0d0b0a'); +}); + +test('effResolve: Boundary — unset with no inherit, or a missing face, gives null', () => { + const m = { a: { f: { fg: null, inherit: null } } }; + assert.equal(effResolve(m, 'a', 'f', 'fg'), null); + assert.equal(effResolve(m, 'a', 'nope', 'fg'), null); +}); + +test('effResolve: Error — an inherit cycle terminates at null, no overflow', () => { + const m = { a: { x: { fg: null, inherit: 'y' }, y: { fg: null, inherit: 'x' } } }; + assert.equal(effResolve(m, 'a', 'x', 'fg'), null); +}); + +test('packagesForExport: Normal — exports sourced faces, omits height 1', () => { + const m = { a: { f: { + fg: '#67809c', bg: null, bold: true, italic: false, underline: false, + strike: false, inherit: null, height: 1, source: 'user', + } } }; + const out = packagesForExport(m); + assert.equal(out.a.f.fg, '#67809c'); + assert.equal(out.a.f.source, 'user'); + assert.ok(!('height' in out.a.f), 'height 1 is omitted'); +}); + +test('packagesForExport: Boundary — keeps a non-default height', () => { + const m = { a: { f: { fg: null, bg: null, source: 'user', height: 1.2 } } }; + assert.equal(packagesForExport(m).a.f.height, 1.2); +}); + +test('packagesForExport: Error — faces with an unknown source are skipped', () => { + const m = { a: { f: { fg: '#67809c', source: 'system' } } }; + assert.deepEqual(packagesForExport(m), {}); +}); + +test('mergePackagesInto: Normal — fills missing fields with defaults', () => { + const m = {}; + mergePackagesInto(m, { a: { f: { fg: '#112233' } } }); + assert.deepEqual(m.a.f, { + fg: '#112233', bg: null, bold: false, italic: false, underline: false, + strike: false, inherit: null, height: 1, box: null, source: 'user', + }); +}); + +test('mergePackagesInto: Boundary — undefined pkgs is a no-op', () => { + const m = { a: { f: { fg: '#000000' } } }; + mergePackagesInto(m, undefined); + assert.deepEqual(m, { a: { f: { fg: '#000000' } } }); +}); + +test('slugify: Normal — spaces and punctuation collapse to single dashes', () => { + assert.equal(slugify('My Cool Theme'), 'My-Cool-Theme'); + assert.equal(slugify('dupre revised'), 'dupre-revised'); + assert.equal(slugify('keeps.dots_and-dashes'), 'keeps.dots_and-dashes'); +}); + +test('slugify: Boundary — leading/trailing junk is trimmed', () => { + assert.equal(slugify(' spaced '), 'spaced'); + assert.equal(slugify('!!!edges!!!'), 'edges'); + assert.equal(slugify(''), 'theme'); // empty falls back +}); + +test('slugify: Error — an all-disallowed name falls back to "theme"', () => { + assert.equal(slugify('!!!'), 'theme'); + assert.equal(slugify(' '), 'theme'); +}); + +// Guards the one-source-of-truth contract, same as the colormath integrity test: +// the page must carry app-core.js's body (sans exports) verbatim. Requires +// `python3 generate.py` to have run first. +const stripExports = (s) => + s.split('\n').filter((l) => !(l.startsWith('export') || l.startsWith('import'))).join('\n').replace(/\s+$/, ''); + +test('inline-integrity: theme-studio.html contains the app-core.js body verbatim', () => { + const body = stripExports(readFileSync(here + 'app-core.js', 'utf8')); + const html = readFileSync(here + 'theme-studio.html', 'utf8'); + assert.ok(html.includes(body), 'generated page is missing the app-core.js body verbatim'); +}); + +test('inline-integrity: theme-studio.html contains palette-actions.js verbatim', () => { + const body = stripExports(readFileSync(here + 'palette-actions.js', 'utf8')); + const html = readFileSync(here + 'theme-studio.html', 'utf8'); + assert.ok(html.includes(body), 'generated page is missing palette-actions.js verbatim'); +}); + +test('inline-integrity: theme-studio.html contains browser-gates.js verbatim', () => { + const body = stripExports(readFileSync(here + 'browser-gates.js', 'utf8')); + const html = readFileSync(here + 'theme-studio.html', 'utf8'); + assert.ok(html.includes(body), 'generated page is missing browser-gates.js verbatim'); +}); |
