Files
root/tests/artwork.test.cjs

192 lines
8.5 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const scheduleCode = fs.readFileSync(path.join(__dirname, '../artwork-schedule.js'), 'utf8');
const artworkCode = fs.readFileSync(path.join(__dirname, '../artwork.js'), 'utf8');
function page({ time = Date.parse('2026-10-07T09:00:00+09:00'), reduced = true, width = 390, height = 480 } = {}) {
let now = time;
let nextTimer = 0;
let balance = 0;
let points = [];
const timers = new Map();
function node() {
const events = {};
const captures = new Set();
return {
events, options: [], value: '', dataset: {}, hidden: false,
style: { setProperty() {}, removeProperty() {} },
append(child) { this.options.push(child); },
addEventListener(name, fn) { (events[name] ??= []).push(fn); },
emit(name, event = {}) { for (const fn of events[name] ?? []) fn(event); },
focus() {},
setPointerCapture(id) { captures.add(id); },
hasPointerCapture(id) { return captures.has(id); },
releasePointerCapture(id) { captures.delete(id); this.emit('lostpointercapture', { pointerId: id }); }
};
}
const field = Object.assign(node(), {
classList: { add() {} }, getBoundingClientRect: () => ({ width, height, left: 0, top: 250 })
});
const picker = node();
const trigger = node();
const controls = Object.assign(node(), { hidden: true, open: true });
const close = node();
const previous = node();
const next = node();
const random = node();
const nodes = { '.artwork': field, '#artwork-picker': picker,
'#developer-trigger': trigger, '.artwork-controls': controls, '#artwork-close': close,
'#artwork-previous': previous, '#artwork-next': next, '#artwork-random': random };
const finite = (...values) => values.filter(value => typeof value === 'number')
.forEach(value => assert(Number.isFinite(value)));
const gradient = { addColorStop(value, color) { assert(value >= 0 && value <= 1); assert(!/NaN|undefined/.test(color)); } };
const context = new Proxy({
clearRect() { points = []; },
save() { balance++; }, restore() { balance--; assert(balance >= 0); },
arc(...args) { finite(...args); assert(args[2] > 0); points.push(args); },
ellipse(...args) { finite(...args); assert(args[2] > 0 && args[3] > 0); },
measureText: () => ({ width: 500 }),
createRadialGradient(...args) { finite(...args); return gradient; },
createLinearGradient(...args) { finite(...args); return gradient; }
}, { get: (object, key) => object[key] ?? finite });
const document = Object.assign(node(), {
hidden: false, documentElement: node(),
querySelector(selector) { assert(nodes[selector], selector); return nodes[selector]; },
createElement: tag => tag === 'canvas' ? { getContext: () => context } : node()
});
const window = node();
const sandbox = {
document, window, Date: { now: () => now }, Math, devicePixelRatio: 2,
innerWidth: width, innerHeight: height + 400,
performance: { now: () => 0 },
getComputedStyle: () => ({ getPropertyValue: () => '#879098' }),
matchMedia: query => ({ matches: query.includes('reduce') && reduced, addEventListener() {} }),
requestAnimationFrame: () => 1,
setTimeout(fn, delay) { const id = ++nextTimer; timers.set(id, { fn, at: now + delay }); return id; },
clearTimeout(id) { timers.delete(id); },
ResizeObserver: class { constructor(fn) { this.fn = fn; } observe() { this.fn(); } },
IntersectionObserver: class { observe() {} }
};
vm.runInNewContext(scheduleCode, sandbox);
vm.runInNewContext(artworkCode, sandbox);
return {
field, picker, trigger, controls, close, previous, next, random, document, window,
schedule: sandbox.NNCArtworkSchedule,
now: () => now,
points: () => JSON.stringify(points),
choose(index) { picker.value = String(index); picker.emit('change'); assert.equal(balance, 0); },
advance(duration) {
const end = now + duration;
while (true) {
const entry = [...timers.entries()].filter(([, timer]) => timer.at <= end)
.sort((a, b) => a[1].at - b[1].at)[0];
if (!entry) break;
now = entry[1].at;
timers.delete(entry[0]);
entry[1].fn();
}
now = end;
}
};
}
function pointer(pointerType = 'mouse', extras = {}) {
return { isPrimary: true, button: 0, pointerId: 1, pointerType, clientX: 340, clientY: 900,
preventDefault() {}, ...extras };
}
test('markup is hidden before scripts run, with a close button and correct script order', () => {
const html = fs.readFileSync(path.join(__dirname, '../index.html'), 'utf8');
assert.match(html, /<details class="artwork-controls" hidden open>/);
assert.match(html, /id="developer-trigger"[^>]*>zenn<\/button>/);
assert.match(html, /id="artwork-close"/);
assert(!html.includes('artwork-name'));
assert(!artworkCode.includes('artwork-name'));
assert(html.indexOf('src="artwork-schedule.js"') < html.indexOf('src="artwork.js"'));
assert(!/Math\.random|sessionStorage|localStorage/.test(artworkCode));
});
for (const pointerType of ['mouse', 'touch']) {
test(`${pointerType} opens the panel at exactly 5 seconds, then X hides it`, () => {
const app = page();
assert(app.controls.hidden);
app.trigger.emit('pointerdown', pointer(pointerType));
app.advance(4999);
assert(app.controls.hidden);
app.advance(1);
assert.equal(app.controls.hidden, false);
app.choose(29);
app.close.emit('click', { preventDefault() {}, stopPropagation() {} });
assert(app.controls.hidden);
assert.equal(Number(app.picker.value), app.schedule.selectionAt(app.now()).variant);
assert(page({ time: app.now() }).controls.hidden);
});
}
test('release, movement, canceled touch, lost capture, blur and hidden tab cancel a pending hold', () => {
for (const abort of ['pointerup', 'pointercancel', 'lostpointercapture', 'move', 'blur', 'hidden']) {
const app = page();
app.trigger.emit('pointerdown', pointer('touch'));
app.advance(4000);
if (abort === 'move') app.trigger.emit('pointermove', pointer('touch', { clientX: 365 }));
else if (abort === 'blur') app.window.emit('blur');
else if (abort === 'hidden') { app.document.hidden = true; app.document.emit('visibilitychange'); }
else app.trigger.emit(abort, pointer('touch'));
app.advance(2000);
assert(app.controls.hidden, abort);
}
});
test('right click never starts a hold; keyboard long press and Escape are supported', () => {
const app = page();
app.trigger.emit('pointerdown', pointer('mouse', { button: 2 }));
app.advance(5000);
assert(app.controls.hidden);
app.trigger.emit('keydown', { key: 'Enter', repeat: false, preventDefault() {} });
app.advance(5000);
assert.equal(app.controls.hidden, false);
app.controls.emit('keydown', { key: 'Escape', preventDefault() {} });
assert(app.controls.hidden);
});
test('reloading the same slot reproduces the artwork and generated particle layout', () => {
const first = page();
const second = page({ time: first.now() + 20000 });
assert.equal(first.field.dataset.artwork, second.field.dataset.artwork);
first.choose(19);
second.choose(19);
assert.equal(first.points(), second.points());
});
test('the open page changes at the exact next slot boundary, including with reduced motion', () => {
const app = page();
const initial = Number(app.picker.value);
const remaining = app.schedule.SLOT_DURATION - app.now() % app.schedule.SLOT_DURATION;
app.advance(remaining - 1);
assert.equal(Number(app.picker.value), initial);
app.advance(1);
assert.notEqual(Number(app.picker.value), initial);
assert.equal(Number(app.picker.value), app.schedule.selectionAt(app.now()).variant);
});
test('all 30 previews render; next/previous wrap; closing restores the schedule', () => {
for (const width of [390, 1440, 2560]) {
const app = page({ width });
assert.equal(app.picker.options.length, 30);
for (let index = 0; index < 30; index++) {
app.choose(index);
assert.equal(app.picker.value, String(index));
assert.match(app.picker.options[index].textContent, /^\d{2} \/ .+/);
}
app.next.emit('click'); assert.equal(app.picker.value, '0');
app.previous.emit('click'); assert.equal(app.picker.value, '29');
app.random.emit('click'); assert.notEqual(app.picker.value, '29');
app.close.emit('click', { preventDefault() {}, stopPropagation() {} });
assert.equal(Number(app.picker.value), app.schedule.selectionAt(app.now()).variant);
}
});