import { test } from 'node:test'; import assert from 'node:assert/strict'; import { TimeController } from '../src/ui/timeController.ts'; class Field extends EventTarget { value = ''; max = ''; step = ''; disabled = false; validity = { valid: true }; attributes = new Map(); textContent = ''; setAttribute(name: string, value: string) { this.attributes.set(name, value); } matches() { return this.disabled; } closest() { return this; } } test('live ticks preserve date edits; custom freezes; live and visibility resync wall clock', (t) => { const fields = new Map(['#date', '#date-display', '#time-slider', '#time-output', '#live-button'].map(id => [id, new Field()])); let dialogOpen = false; const doc = Object.assign(new EventTarget(), { activeElement: null as Field | null, querySelector: (id: string) => id === 'dialog[open]' ? (dialogOpen ? {} : null) : fields.get(id), }); const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); Object.defineProperty(globalThis, 'document', { configurable: true, value: doc }); Object.defineProperty(globalThis, 'window', { configurable: true, value: new EventTarget() }); t.mock.timers.enable({ apis: ['Date', 'setInterval'], now: Date.parse('2026-10-29T00:00:00Z') }); const calls: { date: Date; mode: string }[] = []; const controller = new TimeController((date, mode) => calls.push({ date, mode })); assert.equal(fields.get('#live-button')!.attributes.get('aria-pressed'), 'true'); assert.equal(fields.get('#date-display')!.textContent, '2026년 10월 29일'); const arrow = (key: string) => { const event = Object.assign(new Event('keydown', { cancelable: true }), { key }); window.dispatchEvent(event); return event; }; assert.equal(arrow('ArrowRight').defaultPrevented, true); assert.equal(fields.get('#time-output')!.value, '09:00:10'); assert.equal(controller.mode, 'CUSTOM'); assert.equal(fields.get('#live-button')!.attributes.get('aria-pressed'), 'false'); arrow('ArrowLeft'); assert.equal(fields.get('#time-output')!.value, '09:00:00'); dialogOpen = true; assert.equal(arrow('ArrowRight').defaultPrevented, false); assert.equal(fields.get('#time-output')!.value, '09:00:00'); dialogOpen = false; doc.activeElement = fields.get('#time-slider')!; assert.equal(arrow('ArrowRight').defaultPrevented, false, 'focused slider keeps its native keyboard handling'); doc.activeElement = null; fields.get('#live-button')!.dispatchEvent(new Event('click')); t.after(() => { controller.dispose(); for (const [key, descriptor] of [['document', originalDocument], ['window', originalWindow]] as const) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } }); const date = fields.get('#date')!; doc.activeElement = date; assert.equal(arrow('ArrowRight').defaultPrevented, false, 'date editing keeps its native keyboard handling'); date.value = ''; t.mock.timers.tick(1000); assert.equal(date.value, '', 'live refresh must not overwrite an unfinished date edit'); date.value = '2026-12-21'; fields.get('#time-slider')!.value = '28810'; date.dispatchEvent(new Event('change')); assert.equal(calls.at(-1)!.mode, 'CUSTOM'); assert.equal(calls.at(-1)!.date.toISOString(), '2026-12-20T23:00:10.000Z'); assert.equal(fields.get('#time-output')!.value, '08:00:10'); assert.equal(fields.get('#date-display')!.textContent, '2026년 12월 21일'); assert.equal(fields.get('#live-button')!.attributes.get('aria-pressed'), 'false'); assert.equal(fields.get('#time-slider')!.step, '10'); fields.get('#time-slider')!.value = '86390'; fields.get('#time-slider')!.dispatchEvent(new Event('input')); assert.equal(calls.at(-1)!.date.toISOString(), '2026-12-21T14:59:50.000Z'); assert.equal(fields.get('#time-output')!.value, '23:59:50'); assert.equal(fields.get('#live-button')!.attributes.get('aria-pressed'), 'false'); const count = calls.length; t.mock.timers.tick(3000); doc.dispatchEvent(new Event('visibilitychange')); assert.equal(calls.length, count); doc.activeElement = null; fields.get('#live-button')!.dispatchEvent(new Event('click')); assert.equal(calls.at(-1)!.mode, 'LIVE'); assert.equal(fields.get('#live-button')!.attributes.get('aria-pressed'), 'true'); assert.equal(calls.at(-1)!.date.getTime(), Date.now()); t.mock.timers.setTime(Date.parse('2026-10-30T00:00:00Z')); doc.dispatchEvent(new Event('visibilitychange')); assert.equal(date.value, '2026-10-30'); const serverDate = new Date('2026-11-01T21:00:00Z'); controller.setSharedClock(() => serverDate); assert.equal(fields.get('#date')!.disabled, true); assert.equal(calls.at(-1)!.date.getTime(), +serverDate); doc.activeElement = null; assert.equal(arrow('ArrowRight').defaultPrevented, false); assert.equal(calls.at(-1)!.date.getTime(), +serverDate, 'arrows cannot change shared time'); date.value = '2027-01-01'; date.dispatchEvent(new Event('change')); assert.equal(controller.mode, 'LIVE', 'programmatic events cannot change shared time'); controller.setSharedClock(null); assert.equal(fields.get('#date')!.disabled, false); date.value = '2027-01-01'; date.dispatchEvent(new Event('change')); assert.equal(controller.mode, 'CUSTOM'); });