From dd90eca92f8ffc60094c9e956c8730b94956eb33 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 9 Jun 2026 06:02:49 -0500 Subject: test(theme-studio): extract app-core.js and unit-test the app logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refactor's goal was to make the app logic testable; this realizes it. Pulled the pure package-face model and the dropdown option list into app-core.js — nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve (the inherit-chain resolver behind pkgEffFg/pkgEffBg), and optList — with every dependency passed as a parameter so there is no DOM and no module-global reliance. generate.py inlines it into the page the same way it inlines colormath.js (strip exports, placeholder, integrity check), so the browser runs the same code the tests import. app.js keeps thin wrappers (pname, seedPkgmap, ddList, pkgEffFg, pkgEffBg) that pass the live PALETTE / APPS / PKGMAP into the core, so no call site changed and the built DOM is byte-identical to before. test-app-core.mjs adds 18 Normal/Boundary/Error tests over the extracted logic — name resolution, the seed/export/merge round trip, the inherit chain including a cycle that must terminate at null, and the "(gone)" dropdown entry — plus an inline-integrity check that the page carries the core verbatim. The node suite goes 25 to 43 tests; python templating gains the app-core integrity assertion. --- scripts/theme-studio/test-app-core.mjs | 140 +++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 scripts/theme-studio/test-app-core.mjs (limited to 'scripts/theme-studio/test-app-core.mjs') diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs new file mode 100644 index 00000000..0befeb43 --- /dev/null +++ b/scripts/theme-studio/test-app-core.mjs @@ -0,0 +1,140 @@ +// 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, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, +} 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('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('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, 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, 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' } } }); +}); + +// 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')).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'); +}); -- cgit v1.2.3