aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/test-app-core.mjs
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-09 06:02:49 -0500
committerCraig Jennings <c@cjennings.net>2026-06-09 06:02:49 -0500
commitdd90eca92f8ffc60094c9e956c8730b94956eb33 (patch)
treea53c095e15fa561f47bcbb4fdb01fe2482e8d05b /scripts/theme-studio/test-app-core.mjs
parent1ba9c5aa77ea315b4dd87ae9aa149169e0948d95 (diff)
downloaddotemacs-dd90eca92f8ffc60094c9e956c8730b94956eb33.tar.gz
dotemacs-dd90eca92f8ffc60094c9e956c8730b94956eb33.zip
test(theme-studio): extract app-core.js and unit-test the app logic
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.
Diffstat (limited to 'scripts/theme-studio/test-app-core.mjs')
-rw-r--r--scripts/theme-studio/test-app-core.mjs140
1 files changed, 140 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..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');
+});