// theme_visual — the strict write path and the fail-safe read path. // // The two halves are deliberately asymmetric (see utils/themeResolve.js): a // write is rejected with the offending field named, while a read drops bad // fields one at a time and falls back to the shipped :root. These tests lock // that asymmetry, because it is the thing most likely to get "tidied" into a // single shared check later. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test } = require('node:test') const assert = require('node:assert/strict') const { validateThemeVisual, resolveThemeTokens, themeOptions } = require('../src/utils/themeResolve') const { PRESETS, FONT_OPTIONS, SHADOW_OPTIONS } = require('../src/config/themePresets') // ── validateThemeVisual (write path) ────────────────────────────────── test('accepts a bare preset choice', () => { for (const preset of ['runic-gateway', 'modern', 'fantasy', 'custom']) { assert.equal(validateThemeVisual({ preset }).ok, true, preset) } assert.equal(validateThemeVisual({ preset: 'fantasy', custom: null }).ok, true) }) test('rejects an unknown preset', () => { const res = validateThemeVisual({ preset: 'parchment' }) assert.equal(res.ok, false) assert.match(res.message, /preset must be one of/) }) test('rejects a non-object, and unknown top-level fields', () => { for (const bad of [null, 4, 'fantasy', []]) { assert.equal(validateThemeVisual(bad).ok, false) } const res = validateThemeVisual({ preset: 'modern', mode: 'light' }) assert.equal(res.ok, false) assert.match(res.message, /unknown field\(s\) mode/) }) test('accepts custom colors, fonts and structure from the closed sets', () => { const res = validateThemeVisual({ preset: 'custom', custom: { colors: { accent: '#c9973f', bg: '#000' }, fonts: { display: 'Cinzel, Georgia, serif', sans: 'Inter, Arial, sans-serif' }, structure: { radiusCard: '4px', shadowDepth: SHADOW_OPTIONS[0].value }, }, }) assert.equal(res.ok, true, res.message) }) test('rejects a color that is not a hex literal', () => { // The point of the closed set: a CSS function or keyword never reaches a // custom property value, whatever it would or would not have done there. for (const bad of ['red', 'rgb(1,2,3)', 'url(http://x/y)', '#12345', 'var(--bg)', '#ff0000; x']) { const res = validateThemeVisual({ preset: 'custom', custom: { colors: { accent: bad } } }) assert.equal(res.ok, false, bad) assert.match(res.message, /colors: invalid value for accent/) } }) test('rejects a font stack that is not on the shortlist', () => { const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Comic Sans MS, sans-serif' } } }) assert.equal(res.ok, false) assert.match(res.message, /fonts: invalid value for sans/) }) test('rejects a font offered for a different role', () => { // Cinzel is a display face and is not in the sans list. const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { sans: 'Cinzel, Georgia, serif' } } }) assert.equal(res.ok, false) }) test('rejects an out-of-range or unitless radius', () => { for (const bad of ['1000px', '4', '4em', '-4px', 'calc(4px + 1px)']) { const res = validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: bad } } }) assert.equal(res.ok, false, bad) } assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { radiusCard: '999px' } } }).ok, true) }) test('rejects a shadow that is not one of the listed depths', () => { const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: '0 0 99px red' } } }) assert.equal(res.ok, false) }) test('rejects unknown groups and unknown fields inside a group', () => { assert.equal(validateThemeVisual({ preset: 'custom', custom: { spacing: { unit: '8px' } } }).ok, false) const res = validateThemeVisual({ preset: 'custom', custom: { colors: { border: '#fff' } } }) assert.equal(res.ok, false) assert.match(res.message, /invalid value for border/) }) // ── resolveThemeTokens (read path) ──────────────────────────────────── // The acceptance criterion the whole feature rests on: absent means absent, and // the caller writes nothing. test('no row, or an unusable one, resolves to null', () => { for (const nothing of [null, undefined, '', 'not json', '4', '"x"', '[]', '{}', '{"preset":"custom"}']) { assert.equal(resolveThemeTokens(nothing), null, JSON.stringify(nothing)) } }) test('a preset resolves to its full palette', () => { const tokens = resolveThemeTokens(JSON.stringify({ preset: 'fantasy' })) assert.deepEqual(tokens, PRESETS.fantasy.tokens) // Not a partial palette: the supporting shades move with it, or a warm theme // keeps dark-blue borders. assert.equal(tokens['--line'], '#4a3721') assert.equal(tokens['--blue'], '#382613') }) test('semantic status colors are never themed', () => { for (const preset of Object.values(PRESETS)) { assert.equal('--mode-live' in preset.tokens, false) assert.equal('--mode-maint' in preset.tokens, false) } }) test('the derived panel gradient is never written as a literal', () => { for (const preset of Object.values(PRESETS)) { assert.equal('--panel-grad' in preset.tokens, false) } }) test('runic-gateway is exactly the shipped stylesheet values', () => { const t = PRESETS['runic-gateway'].tokens assert.equal(t['--bg'], '#0e1318') assert.equal(t['--accent'], '#7f99bd') assert.equal(t['--radius-pill'], '999px') assert.equal(t['--radius-panel'], '12px') assert.equal(t['--radius-card'], '10px') assert.equal(t['--radius-input'], '8px') assert.equal(t['--serif'], 'Georgia, "Times New Roman", serif') assert.equal(t['--display'], 'Cinzel, Georgia, serif') assert.equal(t['--sans'], '"Helvetica Neue", Arial, sans-serif') }) // The one place the server duplicates the stylesheet, so the one place that can // drift: switching to runic-gateway after trying another preset must land back // on exactly what :root ships, not on a stale copy of it. test('the runic-gateway preset matches theme.css :root token for token', () => { const fs = require('node:fs') const path = require('node:path') const cssPath = path.join(__dirname, '..', '..', 'client', 'src', 'styles', 'theme.css') const root = /:root\s*\{([\s\S]*?)\}/.exec(fs.readFileSync(cssPath, 'utf8')) assert.ok(root, 'theme.css has a :root block') const declared = {} for (const line of root[1].split(';')) { const m = /^\s*(--[a-z0-9-]+)\s*:\s*([\s\S]+?)\s*$/i.exec(line.replace(/\/\*[\s\S]*?\*\//g, '')) if (m) declared[m[1]] = m[2] } for (const [token, value] of Object.entries(PRESETS['runic-gateway'].tokens)) { // --shadow-card is the exception: theme.css writes rgba() unspaced and the // preset writes it spaced, which is the same computed value. Compare with // whitespace normalized rather than exempting the token entirely. assert.equal( String(declared[token]).replace(/\s+/g, ''), value.replace(/\s+/g, ''), `${token} drifted from theme.css`, ) } }) test('custom fields layer on top of the preset, per field', () => { const tokens = resolveThemeTokens( JSON.stringify({ preset: 'fantasy', custom: { colors: { accent: '#ffffff' } } }), ) assert.equal(tokens['--accent'], '#ffffff') // overridden assert.equal(tokens['--bg'], PRESETS.fantasy.tokens['--bg']) // untouched assert.equal(tokens['--radius-card'], '2px') // untouched }) test('custom with no preset base yields only the fields that were set', () => { const tokens = resolveThemeTokens( JSON.stringify({ preset: 'custom', custom: { structure: { radiusCard: '4px' } } }), ) assert.deepEqual(tokens, { '--radius-card': '4px' }) }) // Fail-safe, field by field: a hand-edited row degrades to the shipped default // for the bad field only, rather than rendering a broken site or throwing. test('an invalid field is dropped without discarding its neighbours', () => { const tokens = resolveThemeTokens( JSON.stringify({ preset: 'custom', custom: { colors: { accent: 'red', bg: '#000000' } } }), ) assert.deepEqual(tokens, { '--bg': '#000000' }) }) test('an unknown preset still applies the custom fields', () => { const tokens = resolveThemeTokens( JSON.stringify({ preset: 'parchment', custom: { colors: { accent: '#ffffff' } } }), ) assert.deepEqual(tokens, { '--accent': '#ffffff' }) }) test('accepts an already-parsed object as well as the stored string', () => { assert.deepEqual(resolveThemeTokens({ preset: 'modern' }), PRESETS.modern.tokens) }) test('every resolved value is a plain string', () => { const tokens = resolveThemeTokens(JSON.stringify({ preset: 'modern' })) for (const [name, value] of Object.entries(tokens)) { assert.match(name, /^--[a-z-]+$/, name) assert.equal(typeof value, 'string', name) } }) // ── themeOptions (the catalog the admin form is built from) ─────────── // The reason the catalog is served rather than duplicated client-side: every // option offered must be one validateThemeVisual() accepts. test('every offered font and shadow validates', () => { const opts = themeOptions() for (const [role, options] of Object.entries(opts.fonts)) { for (const o of options) { const res = validateThemeVisual({ preset: 'custom', custom: { fonts: { [role]: o.value } } }) assert.equal(res.ok, true, `${role}: ${o.value} — ${res.message || ''}`) } } for (const o of opts.shadows) { const res = validateThemeVisual({ preset: 'custom', custom: { structure: { shadowDepth: o.value } } }) assert.equal(res.ok, true, o.value) } }) test('every offered preset id validates and every field name is editable', () => { const opts = themeOptions() for (const p of opts.presets) { assert.equal(validateThemeVisual({ preset: p.id }).ok, true, p.id) } for (const { name } of opts.colorFields) { assert.equal(validateThemeVisual({ preset: 'custom', custom: { colors: { [name]: '#123456' } } }).ok, true, name) } for (const { name } of opts.radiusFields) { assert.equal(validateThemeVisual({ preset: 'custom', custom: { structure: { [name]: '5px' } } }).ok, true, name) } }) // The form reads each control's current value out of the preset map by token // name, so every advertised field must actually resolve to one. test('every advertised field names a token the presets declare', () => { const opts = themeOptions() for (const { name, token } of [...opts.colorFields, ...opts.radiusFields]) { assert.ok(token.startsWith('--'), `${name} → ${token}`) assert.ok(token in opts.shippedTokens, `${token} missing from the shipped theme`) for (const p of opts.presets) { if (p.tokens) assert.ok(token in p.tokens, `${token} missing from preset ${p.id}`) } } }) test('the shipped default font stacks are reachable from the shortlist', () => { // An admin who customizes fonts must be able to get back to today's look // without resetting the whole theme. const serifValues = FONT_OPTIONS.serif.map((o) => o.value) const sansValues = FONT_OPTIONS.sans.map((o) => o.value) const displayValues = FONT_OPTIONS.display.map((o) => o.value) assert.ok(serifValues.includes('Georgia, "Times New Roman", serif')) assert.ok(sansValues.includes('"Helvetica Neue", Arial, sans-serif')) assert.ok(displayValues.includes('Cinzel, Georgia, serif')) })