2026-10-04 08:59:15 +08:00
|
|
|
const test = require('node:test');
|
|
|
|
|
const assert = require('node:assert/strict');
|
|
|
|
|
const fs = require('node:fs');
|
|
|
|
|
const path = require('node:path');
|
|
|
|
|
const css = fs.readFileSync(path.join(__dirname, 'web/chinese-theme.css'), 'utf8');
|
2026-10-05 20:23:05 +08:00
|
|
|
const html = require('./test-support/ui-source')();
|
2026-10-04 08:59:15 +08:00
|
|
|
const root = css.match(/:root\s*\{([^}]+)\}/)[1];
|
|
|
|
|
const token = name => root.match(new RegExp('--' + name + ':\\s*(#[0-9a-f]{6})'))[1];
|
|
|
|
|
function luminance(hex) {
|
|
|
|
|
const values = hex.slice(1).match(/../g).map(v => parseInt(v, 16) / 255)
|
|
|
|
|
.map(v => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4);
|
|
|
|
|
return values[0] * .2126 + values[1] * .7152 + values[2] * .0722;
|
|
|
|
|
}
|
|
|
|
|
function contrast(a, b) {
|
|
|
|
|
const x = luminance(a), y = luminance(b);
|
|
|
|
|
return (Math.max(x, y) + .05) / (Math.min(x, y) + .05);
|
|
|
|
|
}
|
2026-10-05 20:23:05 +08:00
|
|
|
test('Vue theme builds locally after Bootstrap without remote fonts', () => {
|
|
|
|
|
const main=fs.readFileSync('frontend/src/main.js','utf8');
|
|
|
|
|
assert.ok(main.indexOf('bootstrap/dist/css/bootstrap.min.css')<main.indexOf('./theme.css'));
|
|
|
|
|
assert.doesNotMatch(fs.readFileSync('frontend/src/theme.css','utf8'), /@import|https?:|@font-face/i);
|
|
|
|
|
assert.match(fs.readFileSync('frontend/index.html','utf8'), /name="viewport"/);
|
2026-10-04 08:59:15 +08:00
|
|
|
});
|
|
|
|
|
test('primary text, muted text and actions have at least 4.5:1 contrast', () => {
|
|
|
|
|
for (const [fg, bg] of [
|
|
|
|
|
[token('ink'), token('paper')], [token('muted'), token('paper')],
|
|
|
|
|
[token('muted'), token('panel')], ['#fffdf6', token('accent')],
|
|
|
|
|
['#c9d1be', '#213d34'], ['#244638', '#efebdc'],
|
|
|
|
|
['#355b3d', '#e4eddf'], ['#835428', '#f2e8d9'],
|
|
|
|
|
['#994437', '#fbf8ef'], ['#d9dfca', '#24372b'],
|
|
|
|
|
]) assert.ok(contrast(fg, bg) >= 4.5, `${fg} on ${bg}: ${contrast(fg, bg).toFixed(2)}`);
|
|
|
|
|
});
|
|
|
|
|
test('monitor fullscreen, reduced motion and mobile navigation are explicitly styled', () => {
|
|
|
|
|
assert.match(css, /#page-live:fullscreen\s*\{[^}]+color-scheme: dark/);
|
|
|
|
|
assert.match(css, /@media \(prefers-reduced-motion: reduce\)/);
|
|
|
|
|
assert.match(css, /overflow-x: auto/);
|
|
|
|
|
assert.match(css, /\.family-node\.self\s*\{[^}]+background: #f1ddb0/);
|
|
|
|
|
assert.match(css, /--timeline-paper:/);
|
|
|
|
|
});
|
|
|
|
|
test('recording timeline paints with theme tokens and keeps range geometry', () => {
|
|
|
|
|
const vm = require('node:vm');
|
|
|
|
|
const source = fs.readFileSync(path.join(__dirname, 'web/recording-ui.js'), 'utf8');
|
|
|
|
|
const draw = source.slice(source.indexOf('function drawTimeline()'), source.indexOf('function playbackParams('));
|
|
|
|
|
const rectangles = [];
|
|
|
|
|
const ctx = { scale() {}, beginPath() {}, moveTo() {}, lineTo() {}, stroke() {},
|
|
|
|
|
fillRect(...coordinates) { rectangles.push({ color: this.fillStyle, coordinates }); } };
|
|
|
|
|
const canvas = { clientWidth: 480, getContext: () => ctx };
|
|
|
|
|
const values = { '--timeline-paper': '#ede8dd', '--timeline-grid': '#d3ccbc', '--timeline-range': '#4c7761' };
|
|
|
|
|
const scope = vm.createContext({ $: () => canvas, window: { devicePixelRatio: 2 }, document: { documentElement: {} },
|
|
|
|
|
getComputedStyle: () => ({ getPropertyValue: name => values[name] }),
|
|
|
|
|
queryStart: new Date(0), queryEnd: new Date(60000), spans: [{ start: 10000, end: 20000 }] });
|
|
|
|
|
vm.runInContext(draw + ';drawTimeline()', scope);
|
|
|
|
|
assert.equal(canvas.width, 960);
|
|
|
|
|
assert.equal(canvas.height, 152);
|
|
|
|
|
assert.deepEqual(rectangles, [
|
|
|
|
|
{ color: '#ede8dd', coordinates: [0, 0, 480, 76] },
|
|
|
|
|
{ color: '#4c7761', coordinates: [80, 25, 80, 25] },
|
|
|
|
|
]);
|
|
|
|
|
assert.equal(ctx.strokeStyle, '#d3ccbc');
|
|
|
|
|
});
|