From 092a2312a1d2fa1364b1d5cb9c2d71a8aefaeb8e Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 9 Jun 2026 07:45:18 -0500 Subject: test(theme-studio): extract color/slug helpers to importable modules and cover them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure helpers that were still stranded in app.js — normHex, ratingColor, textOn, and the filename-slug logic — had no unit tests because app.js can't be imported (it runs its bootstrap and references the data placeholders at load). Moved them into importable modules so they can be tested directly: a new app-util.js holds the color/UI-boundary trio, and slugify joins app-core.js. app.js keeps thin wrappers, so no call site changed and the built DOM is byte-identical. textOn needs rl from colormath, so generate.py's inline strip now drops import lines as well as export lines — app-util.js imports rl for its tests, and the import is stripped on inline where rl is already in the page. _faces in generate.py also gets direct tests for its prefix-strip and label derivation. New: 12 node tests (normHex, ratingColor, textOn, slugify) and 7 python tests (_faces, app-util integrity, the import strip). Coverage: app-util.js 100/100/100, app-core.js 100/94.9/100, colormath.js 100/96/100 (line/branch/func); generate.py 89% lines (the rest is the __main__ writer and the optional seed-env branch). No bugs surfaced — the logic was correct, just untested. --- scripts/theme-studio/app-core.js | 6 ++- scripts/theme-studio/app-util.js | 20 ++++++++++ scripts/theme-studio/app.js | 11 +++--- scripts/theme-studio/generate.py | 23 +++++++---- scripts/theme-studio/test-app-core.mjs | 19 ++++++++- scripts/theme-studio/test-app-util.mjs | 70 ++++++++++++++++++++++++++++++++++ scripts/theme-studio/test_generate.py | 42 +++++++++++++++++++- scripts/theme-studio/theme-studio.html | 29 +++++++++++--- 8 files changed, 199 insertions(+), 21 deletions(-) create mode 100644 scripts/theme-studio/app-util.js create mode 100644 scripts/theme-studio/test-app-util.mjs (limited to 'scripts') diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 34fb8051..91b9b1a9 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -25,4 +25,8 @@ function effResolve(map,app,face,attr,seen){seen=seen||{};const f=map[app]&&map[ // cur is set but no longer in the palette, surface it as a "(gone)" entry first. function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);return [['','— default —'],...(have?palette:[[cur,'(gone) '+cur],...palette])];} -export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList }; +// Turn a theme name into a safe filename slug: collapse runs of disallowed +// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'. +function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';} + +export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify }; diff --git a/scripts/theme-studio/app-util.js b/scripts/theme-studio/app-util.js new file mode 100644 index 00000000..e3f76dd8 --- /dev/null +++ b/scripts/theme-studio/app-util.js @@ -0,0 +1,20 @@ +// Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status +// color, and the readable text color for a background. These are kept out of +// colormath.js (the pure math core) but are unit-tested and inlined into the page +// the same way. textOn leans on rl from colormath; the import is for the tests — +// generate.py strips it on inline, where rl is already present from the inlined +// colormath core. +import { rl } from './colormath.js'; + +// Normalize a hex string: trim, accept an optional leading #, require exactly six +// hex digits, lowercase the result. Returns null for anything else. +function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;} + +// Map a WCAG contrast ratio to a status color: AAA green (>=7), AA grey (>=4.5), +// otherwise the fail red. +function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';} + +// Pick black or white text for a background hex, by WCAG relative luminance. +function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';} + +export { normHex, ratingColor, textOn }; diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index 6767accf..c5a618e3 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -11,14 +11,14 @@ function seedPkgmap(){return buildPkgmap(APPS,PALETTE);} let PKGMAP=seedPkgmap(); function esc(t){return t.replace(/&/g,'&').replace(//g,'>');} // Pure color-math core (lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex, -// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. normHex, -// textOn, and ratingColor stay below as UI-boundary helpers. +// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. COLORMATH_J // Pure package-model + dropdown logic, inlined verbatim from app-core.js. The // wrappers above (pname/seedPkgmap/ddList/pkgEffFg/pkgEffBg) delegate here. APP_CORE_J -function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';} -function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';} +// Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from +// app-util.js. textOn uses rl from the colormath core above. +APP_UTIL_J // The contrast-cell readout shared by every table: a WCAG ratio colored by its // AA/AAA rating, with the rating word. Callers compute r for their own fg/bg. function crHtml(r){return `${r.toFixed(1)} ${rating(r)}`;} @@ -192,7 +192,6 @@ function updateColor(){ for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false); } -function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;} function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} let pkH=0,pkS=0,pkV=0.5,pickerOn=false; let pkMode='any'; // contrast mask: any / aa / aaa (what constraint to mask) @@ -269,7 +268,7 @@ function addColor(){const h=curHex();const name=document.getElementById('newname if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;} PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);} function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';} -function fileSlug(){return themeName().replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';} +function fileSlug(){return slugify(themeName());} function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;} function exportState(){const t=document.getElementById('export');t.value=JSON.stringify(exportObj(),null,1);t.style.display='block';t.focus();t.select();} function toggleJSON(){const t=document.getElementById('export'),b=document.getElementById('jsonbtn');if(t.style.display==='block'){t.style.display='none';b.textContent='show';}else{exportState();b.textContent='hide';}} diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index 24ad7a1b..56aa5800 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -2,15 +2,19 @@ import json, os HERE=os.path.dirname(os.path.abspath(__file__)) def strip_exports(src): - """Drop ES-module `export` lines so the body loads as a classic """ def fill_data(s): return (s.replace("COLORMATH_J",COLORMATH_BODY) .replace("APP_CORE_J",APP_CORE_BODY) + .replace("APP_UTIL_J",APP_UTIL_BODY) .replace("SAMPLES_J",json.dumps(SAMPLES)) .replace("PALETTE_J",json.dumps(PALETTE)).replace("CATS_J",json.dumps(CATS)) .replace("UIFACES_J",json.dumps(UI_FACES)).replace("UIMAP_J",json.dumps(UIMAP)).replace("APPS_J",json.dumps(APPS)) diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs index 0befeb43..9bf5145f 100644 --- a/scripts/theme-studio/test-app-core.mjs +++ b/scripts/theme-studio/test-app-core.mjs @@ -7,7 +7,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { - nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, + nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, } from './app-core.js'; const here = fileURLToPath(new URL('.', import.meta.url)); @@ -127,6 +127,23 @@ test('mergePackagesInto: Boundary — undefined pkgs is a no-op', () => { 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. diff --git a/scripts/theme-studio/test-app-util.mjs b/scripts/theme-studio/test-app-util.mjs new file mode 100644 index 00000000..2cb08e0e --- /dev/null +++ b/scripts/theme-studio/test-app-util.mjs @@ -0,0 +1,70 @@ +// Unit tests for the pure color/UI-boundary helpers (app-util.js). +// 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 { normHex, ratingColor, textOn } from './app-util.js'; + +const here = fileURLToPath(new URL('.', import.meta.url)); + +test('normHex: Normal — adds the #, lowercases, accepts an existing #', () => { + assert.equal(normHex('67809C'), '#67809c'); + assert.equal(normHex('#E8BD30'), '#e8bd30'); + assert.equal(normHex('#67809c'), '#67809c'); +}); + +test('normHex: Boundary — trims surrounding whitespace; empty is null', () => { + assert.equal(normHex(' 67809c '), '#67809c'); + assert.equal(normHex(''), null); + assert.equal(normHex(' '), null); + assert.equal(normHex('abc'), null); // 3-digit shorthand is not accepted +}); + +test('normHex: Error — bad characters and wrong length give null', () => { + assert.equal(normHex('#gggggg'), null); + assert.equal(normHex('#12345'), null); // 5 digits + assert.equal(normHex('#1234567'), null); // 7 digits + assert.equal(normHex('red'), null); +}); + +test('ratingColor: Normal — AAA green, AA grey, fail red', () => { + assert.equal(ratingColor(10), '#5d9b86'); + assert.equal(ratingColor(5), '#a9b2bb'); + assert.equal(ratingColor(2), '#cb6b4d'); +}); + +test('ratingColor: Boundary — the AAA (7) and AA (4.5) thresholds are inclusive', () => { + assert.equal(ratingColor(7), '#5d9b86'); + assert.equal(ratingColor(6.99), '#a9b2bb'); + assert.equal(ratingColor(4.5), '#a9b2bb'); + assert.equal(ratingColor(4.49), '#cb6b4d'); +}); + +test('ratingColor: Error — zero and negative ratios are the fail color', () => { + assert.equal(ratingColor(0), '#cb6b4d'); + assert.equal(ratingColor(-1), '#cb6b4d'); +}); + +test('textOn: Normal — white text on black, black text on white', () => { + assert.equal(textOn('#000000'), '#fff'); + assert.equal(textOn('#ffffff'), '#000'); +}); + +test('textOn: Boundary — straddles the ~0.179 luminance crossover', () => { + assert.equal(textOn('#707070'), '#fff'); // just below the crossover + assert.equal(textOn('#777777'), '#000'); // just above the crossover +}); + +// Inline-integrity: the page must carry app-util.js's body (sans import/export) +// verbatim — the same strip generate.py applies. Requires `python3 generate.py`. +const stripModule = (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-util.js body verbatim', () => { + const body = stripModule(readFileSync(here + 'app-util.js', 'utf8')); + const html = readFileSync(here + 'theme-studio.html', 'utf8'); + assert.ok(html.includes(body), 'generated page is missing the app-util.js body verbatim'); +}); diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py index 077382e4..ee13f8de 100644 --- a/scripts/theme-studio/test_generate.py +++ b/scripts/theme-studio/test_generate.py @@ -30,6 +30,12 @@ class StripExports(unittest.TestCase): src = "export const a=1;\ncode();\nexport { a };" self.assertEqual(generate.strip_exports(src), "code();") + def test_removes_import_lines_too(self): + # A pure module may import a peer for its own tests; the import must be + # stripped on inline (the peer is already in the page). + src = "import { rl } from './colormath.js';\nfunction f(){return rl();}" + self.assertEqual(generate.strip_exports(src), "function f(){return rl();}") + def test_matches_the_js_side_strip_so_integrity_holds(self): # test-colormath.mjs strips with the same rule: drop lines starting with # 'export', then trim trailing whitespace. Keep the two in lockstep. @@ -62,7 +68,7 @@ class ColormathInlining(unittest.TestCase): class AssembledPage(unittest.TestCase): PLACEHOLDERS = [ - "STYLES_CSS", "APP_JS", "APP_CORE_J", + "STYLES_CSS", "APP_JS", "APP_CORE_J", "APP_UTIL_J", "COLORMATH_J", "SAMPLES_J", "PALETTE_J", "CATS_J", "UIFACES_J", "UIMAP_J", "APPS_J", "BOLD_J", "MAP_J", ] @@ -81,6 +87,15 @@ class AssembledPage(unittest.TestCase): # and the unit-tested module cannot drift. self.assertIn(generate.APP_CORE_BODY, generate.HTML) + def test_page_carries_the_app_util_body_verbatim(self): + # app-util.js inlines verbatim after its import line is stripped. + self.assertIn(generate.APP_UTIL_BODY, generate.HTML) + + def test_app_util_inlined_body_has_no_import_line(self): + # The `import rl` line must be gone, or the page "), 1) +class FacesHelper(unittest.TestCase): + def test_strips_prefix_and_derives_label_and_merges_seed(self): + # Normal: the prefix comes off the label, and the per-face seed is attached. + rows = generate._faces(["org-todo", "org-done"], "org-", {"org-todo": {"fg": "gold"}}) + self.assertEqual(rows, [ + ["org-todo", "todo", {"fg": "gold"}], + ["org-done", "done", {}], + ]) + + def test_label_drops_face_suffix_and_spaces_remaining_dashes(self): + # Boundary: "-face" is removed and the rest of the dashes become spaces. + rows = generate._faces(["lsp-rename-placeholder-face"], "lsp-", {}) + self.assertEqual(rows[0][1], "rename placeholder") + + def test_name_without_the_prefix_is_left_intact(self): + # Boundary: a name that doesn't start with the prefix keeps its full text + # (only "-face" removal and dash-spacing apply). + rows = generate._faces(["shr-text"], "org-", {}) + self.assertEqual(rows[0], ["shr-text", "shr text", {}]) + + def test_empty_names_gives_empty_list(self): + # Error/Boundary: nothing in, nothing out. + self.assertEqual(generate._faces([], "org-", {"org-todo": {"fg": "gold"}}), []) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 219a4a03..0e04d012 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -186,8 +186,7 @@ function seedPkgmap(){return buildPkgmap(APPS,PALETTE);} let PKGMAP=seedPkgmap(); function esc(t){return t.replace(/&/g,'&').replace(//g,'>');} // Pure color-math core (lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex, -// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. normHex, -// textOn, and ratingColor stay below as UI-boundary helpers. +// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. // colormath.js — pure color-math core for theme-studio. // // One source of truth: node imports this module (tests); generate.py inlines its @@ -407,8 +406,29 @@ function effResolve(map,app,face,attr,seen){seen=seen||{};const f=map[app]&&map[ // Standard swatch-dropdown option list: a default entry, then the palette. When // cur is set but no longer in the palette, surface it as a "(gone)" entry first. function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);return [['','— default —'],...(have?palette:[[cur,'(gone) '+cur],...palette])];} -function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';} + +// Turn a theme name into a safe filename slug: collapse runs of disallowed +// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'. +function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';} +// Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from +// app-util.js. textOn uses rl from the colormath core above. +// Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status +// color, and the readable text color for a background. These are kept out of +// colormath.js (the pure math core) but are unit-tested and inlined into the page +// the same way. textOn leans on rl from colormath; the import is for the tests — +// generate.py strips it on inline, where rl is already present from the inlined +// colormath core. + +// Normalize a hex string: trim, accept an optional leading #, require exactly six +// hex digits, lowercase the result. Returns null for anything else. +function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;} + +// Map a WCAG contrast ratio to a status color: AAA green (>=7), AA grey (>=4.5), +// otherwise the fail red. function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';} + +// Pick black or white text for a background hex, by WCAG relative luminance. +function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';} // The contrast-cell readout shared by every table: a WCAG ratio colored by its // AA/AAA rating, with the rating word. Callers compute r for their own fg/bg. function crHtml(r){return `${r.toFixed(1)} ${rating(r)}`;} @@ -582,7 +602,6 @@ function updateColor(){ for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false); } -function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;} function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} let pkH=0,pkS=0,pkV=0.5,pickerOn=false; let pkMode='any'; // contrast mask: any / aa / aaa (what constraint to mask) @@ -659,7 +678,7 @@ function addColor(){const h=curHex();const name=document.getElementById('newname if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;} PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);} function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';} -function fileSlug(){return themeName().replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';} +function fileSlug(){return slugify(themeName());} function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;} function exportState(){const t=document.getElementById('export');t.value=JSON.stringify(exportObj(),null,1);t.style.display='block';t.focus();t.select();} function toggleJSON(){const t=document.getElementById('export'),b=document.getElementById('jsonbtn');if(t.style.display==='block'){t.style.display='none';b.textContent='show';}else{exportState();b.textContent='hide';}} -- cgit v1.2.3