192 lines
8.5 KiB
JavaScript
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);
|
|
}
|
|
});
|