Files
Haven/test/themeSwitcher.test.js
T

244 lines
10 KiB
JavaScript

'use strict';
// theme.js runs in a small fake page here: enough DOM for the theme picker
// and the Custom editor, so the tests can see which colors end up on :root.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
const ROOT = path.join(__dirname, '..');
const THEME_SOURCE = fs.readFileSync(path.join(ROOT, 'public/js/theme.js'), 'utf8');
const ThemeCompat = require('../public/js/theme-compat');
const { readCoreCss } = require('./coreCss');
function memoryStorage(initial = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: key => (values.has(key) ? values.get(key) : null),
setItem: (key, value) => { values.set(key, String(value)); },
removeItem: key => { values.delete(key); },
has: key => values.has(key),
};
}
function inlineStyle() {
const props = new Map();
return {
props,
setProperty: (key, value) => { props.set(key, String(value)); },
removeProperty: key => { props.delete(key); },
getPropertyValue: key => props.get(key) || '',
};
}
function element(id) {
const attributes = new Map();
return {
id,
style: inlineStyle(),
dataset: {},
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
setAttribute: (name, value) => { attributes.set(name, String(value)); },
getAttribute: name => (attributes.has(name) ? attributes.get(name) : null),
removeAttribute: name => { attributes.delete(name); },
hasAttribute: name => attributes.has(name),
addEventListener() {},
querySelectorAll: () => [],
querySelector: () => null,
appendChild: child => child,
};
}
// A 2D context that accepts every drawing call and draws nothing.
function canvasContext() {
return new Proxy({}, {
get(target, key) {
if (key in target) return target[key];
if (key === 'createImageData') return () => ({ data: [] });
return () => {};
},
});
}
function canvas(id) {
return Object.assign(element(id), {
width: 12,
height: 12,
getContext: () => canvasContext(),
getBoundingClientRect: () => ({ left: 0, top: 0, width: 12, height: 12 }),
});
}
function loadThemePage(stored = {}, { computedColor = '' } = {}) {
const page = { computedColor };
const localStorage = memoryStorage(stored);
const sessionStorage = memoryStorage();
const root = element('html');
const nodes = new Map([
['theme-selector', element('theme-selector')],
['custom-theme-editor', element('custom-theme-editor')],
['custom-hue-bar', canvas('custom-hue-bar')],
['custom-triangle', canvas('custom-triangle')],
['custom-theme-swatch', element('custom-theme-swatch')],
]);
const timers = [];
const document = {
documentElement: root,
body: element('body'),
head: element('head'),
hidden: false,
getElementById: id => nodes.get(id) || null,
querySelectorAll: () => [],
querySelector: () => null,
createElement: tag => element(tag),
addEventListener() {},
};
const window = {
location: { href: 'http://localhost/app', search: '' },
HavenThemeCompat: ThemeCompat,
addEventListener() {},
matchMedia: () => ({ matches: false, addEventListener() {} }),
};
const context = {
window,
document,
localStorage,
sessionStorage,
console,
setTimeout: fn => { timers.push(fn); return timers.length; },
clearTimeout() {},
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
getComputedStyle: () => ({ getPropertyValue: () => '', color: page.computedColor }),
URLSearchParams,
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init?.detail; } },
};
window.document = document;
window.localStorage = localStorage;
vm.createContext(context);
vm.runInContext(THEME_SOURCE, context, { filename: 'theme.js' });
const runTimers = () => { while (timers.length) timers.shift()(); };
Object.assign(page, { context, root, nodes, localStorage, runTimers });
return page;
}
test('a fresh account keeps Haven colors instead of the Custom palette', () => {
const page = loadThemePage();
page.context.initThemeSwitcher('theme-selector');
page.runTimers();
assert.equal(page.root.style.props.get('--bg-primary'), undefined, 'no Custom color on :root');
assert.equal(page.root.style.props.get('--accent'), undefined, 'no Custom accent on :root');
assert.equal(page.localStorage.has('haven_custom_hsv'), false, 'loading the page does not save a Custom color');
assert.ok(page.nodes.get('custom-theme-swatch').style.background, 'the Custom button still shows its color');
});
test('a built-in theme is not covered by a Custom color saved earlier', () => {
const page = loadThemePage({
haven_theme: 'discord',
haven_custom_hsv: JSON.stringify({ h: 120, s: 0.8, v: 0.9 }),
});
page.context.initThemeSwitcher('theme-selector');
page.runTimers();
assert.equal(page.root.style.props.get('--bg-primary'), undefined);
});
test('an account that chose Custom keeps its Custom palette', () => {
const hsv = { h: 120, s: 0.8, v: 0.9 };
const page = loadThemePage({ haven_theme: 'custom', haven_custom_hsv: JSON.stringify(hsv) });
page.context.initThemeSwitcher('theme-selector');
page.runTimers();
const expected = page.context.generateCustomPalette(hsv.h, hsv.s, hsv.v, hsv.s, false);
assert.equal(page.root.style.props.get('--bg-primary'), expected['--bg-primary']);
assert.equal(page.root.style.props.get('--accent'), expected['--accent']);
});
test('Custom chosen from the server applies even before a color was ever saved', () => {
const page = loadThemePage();
page.context.initThemeSwitcher('theme-selector');
page.runTimers();
page.context.applyThemeFromServer('custom', true, true);
assert.ok(page.root.style.props.get('--bg-primary'), 'the default Custom palette is applied');
assert.equal(page.localStorage.getItem('haven_theme'), 'custom');
});
test('every theme gets light or dark native controls from its own background', () => {
const light = new Set(['gospel', 'win95', 'daylight', 'cloudy', 'amni-scient-light.theme.css', 'braid-light.theme.css']);
const themes = new Map();
const core = readCoreCss();
for (const [, name, color] of core.matchAll(/\[data-theme="([a-z0-9]+)"\]\s*\{[^}]*?--bg-primary:\s*([^;]+);/g)) themes.set(name, color);
const haven = core.match(/:root,\s*\[data-theme="haven"\]\s*\{[^}]*?--bg-primary:\s*([^;]+);/);
themes.set('haven', haven[1]);
const themeDir = path.join(ROOT, 'themes');
for (const file of fs.readdirSync(themeDir).filter(f => f.endsWith('.theme.css'))) {
const css = fs.readFileSync(path.join(themeDir, file), 'utf8');
themes.set(file, css.match(/--bg-primary:\s*([^;]+);/)[1]);
// A theme that names its scheme must agree with what its colors say.
const declared = css.match(/color-scheme:\s*(light|dark)\s*;/);
if (declared) assert.equal(ThemeCompat.colorSchemeFor(themes.get(file)), declared[1], file);
}
assert.ok(themes.size > 30, 'built-in and file themes were all found');
for (const [name, color] of themes) {
assert.equal(ThemeCompat.colorSchemeFor(color), light.has(name) ? 'light' : 'dark', `${name} (${color})`);
}
});
test('the color scheme reader understands the color syntaxes a theme can use', () => {
assert.equal(ThemeCompat.colorSchemeFor('#fff'), 'light');
assert.equal(ThemeCompat.colorSchemeFor('#000000'), 'dark');
assert.equal(ThemeCompat.colorSchemeFor('#F3F2EF'), 'light');
assert.equal(ThemeCompat.colorSchemeFor('rgb(250, 248, 245)'), 'light');
assert.equal(ThemeCompat.colorSchemeFor('rgb(25 27 40)'), 'dark');
assert.equal(ThemeCompat.colorSchemeFor('rgba(245, 247, 250, 0.95)'), 'light');
assert.equal(ThemeCompat.colorSchemeFor('color(srgb 0.98 0.97 0.96)'), 'light');
assert.equal(ThemeCompat.colorSchemeFor('rgba(0, 0, 0, 0)'), null, 'transparent says nothing');
assert.equal(ThemeCompat.colorSchemeFor('oklch(0.9 0.1 200)'), null);
assert.equal(ThemeCompat.colorSchemeFor(''), null);
assert.equal(ThemeCompat.colorSchemeFor('#12'), null);
});
test('Custom and RGB set the scheme from their light or dark base', () => {
const page = loadThemePage();
for (const hue of [0, 60, 120, 200, 260, 320]) {
page.context.applyCustomVars(page.context.generateCustomPalette(hue, 0.75, 0.95, 0.75, true));
assert.equal(page.root.getAttribute('data-color-scheme'), 'light', `light base, hue ${hue}`);
page.context.applyCustomVars(page.context.generateCustomPalette(hue, 0.75, 0.95, 0.75, false));
assert.equal(page.root.getAttribute('data-color-scheme'), 'dark', `dark base, hue ${hue}`);
}
assert.equal(page.localStorage.getItem('haven_color_scheme'), 'dark', 'stored for the next first paint');
});
test('switching to a built-in theme measures its background again', () => {
const page = loadThemePage({}, { computedColor: 'rgb(25, 27, 40)' });
page.runTimers();
assert.equal(page.root.getAttribute('data-color-scheme'), 'dark');
page.computedColor = 'rgb(250, 248, 245)';
page.context.applyThemeFromServer('daylight', true, true);
page.runTimers();
assert.equal(page.root.getAttribute('data-color-scheme'), 'light');
assert.equal(page.localStorage.getItem('haven_color_scheme'), 'light');
});
test('every native select and its options use the theme input colors', () => {
const core = readCoreCss();
assert.match(core, /:where\(:root\)\s*\{\s*color-scheme:\s*dark;\s*\}/);
assert.match(core, /:where\(:root\[data-color-scheme="light"\]\)\s*\{\s*color-scheme:\s*light;\s*\}/);
const select = core.match(/:where\(select\)\s*\{([^}]*)\}/);
assert.ok(select, 'a base rule for every select');
assert.match(select[1], /background-color:\s*var\(--bg-input\)/);
assert.match(select[1], /color:\s*var\(--text-primary\)/);
assert.match(select[1], /border:\s*1px solid var\(--border\)/);
assert.match(core, /:where\(select:focus\)\s*\{\s*border-color:\s*var\(--accent\);\s*\}/);
const option = core.match(/:where\(select option, select optgroup\)\s*\{([^}]*)\}/);
assert.ok(option, 'a base rule for every option');
assert.match(option[1], /background-color:\s*var\(--bg-input\)/);
assert.match(option[1], /color:\s*var\(--text-primary\)/);
});