diff --git a/src/arts/timer/fake-clock.ts b/src/arts/timer/fake-clock.ts new file mode 100644 index 0000000..16e980c --- /dev/null +++ b/src/arts/timer/fake-clock.ts @@ -0,0 +1,100 @@ +import type { TimerClock, TimerNativeHandle } from '$libs/timer'; + +interface FakeClockEntry { + id: number; + dueAt: number; + fn: () => void; + cancelled: boolean; +} + +/** + * Test-only TimerClock with manual time advancement. Same shape that + * `createEngineTimers({ clock })` and the format/storage `clock` + * options accept, so any artifact under test can be wired against this + * deterministic source without touching `vi.useFakeTimers()`. + * + * Methods: + * - `advanceBy(ms)` — fires every entry whose `dueAt` is reached during + * the advancement, in due-order, with microtask flushes between each + * so awaited promises inside callbacks resolve before time moves on. + * - `advanceTo(ms)` — convenience over `advanceBy`. + * - `pendingCount()` — for tests that want to assert teardown is clean. + */ +export interface FakeTimerClock extends TimerClock { + advanceBy(ms: number): Promise; + advanceTo(ms: number): Promise; + pendingCount(): number; +} + +/** + * Build a fake `TimerClock` with manual time advancement. Use in tests + * for any artifact that accepts a `clock` injection (storage, format, + * logger, http, session auto-refresh, …). + * + * @example + * const clock = createFakeTimerClock(0); + * const timers = createEngineTimers({ clock }); + * timers.schedule('demo', 100, () => fired = true); + * await clock.advanceBy(150); + * expect(fired).toBe(true); + */ +export function createFakeTimerClock(start = 0): FakeTimerClock { + let now = start; + const queue = new Map(); + let nextId = 1; + + function fire(entry: FakeClockEntry): void { + if (entry.cancelled) return; + queue.delete(entry.id); + entry.fn(); + } + + async function flushMicrotasks(): Promise { + // Yield to the microtask queue so any awaited promises inside + // scheduled callbacks resolve before we advance further. + for (let i = 0; i < 10; i++) await Promise.resolve(); + } + + const clock: FakeTimerClock = { + now: () => now, + setTimeout: (fn: () => void, delayMs: number): TimerNativeHandle => { + const entry: FakeClockEntry = { + id: nextId++, + dueAt: now + delayMs, + fn, + cancelled: false + }; + queue.set(entry.id, entry); + return entry.id; + }, + clearTimeout: (handle: TimerNativeHandle): void => { + const entry = queue.get(handle as number); + if (entry !== undefined) { + entry.cancelled = true; + queue.delete(entry.id); + } + }, + async advanceBy(ms: number): Promise { + const target = now + ms; + while (true) { + let next: FakeClockEntry | undefined; + for (const e of queue.values()) { + if (e.dueAt > target) continue; + if (next === undefined || e.dueAt < next.dueAt) next = e; + } + if (next === undefined) break; + now = next.dueAt; + fire(next); + await flushMicrotasks(); + } + now = target; + await flushMicrotasks(); + }, + async advanceTo(target: number): Promise { + if (target <= now) return; + await clock.advanceBy(target - now); + }, + pendingCount: () => queue.size + }; + return clock; +} diff --git a/src/arts/timer/index.ts b/src/arts/timer/index.ts index 119e0ab..ae2fc02 100644 --- a/src/arts/timer/index.ts +++ b/src/arts/timer/index.ts @@ -5,6 +5,7 @@ export { createEngineTimers } from './engine-timers.ts'; export { createActiveTimers, type ActiveTimers } from './active-timers.svelte.ts'; export { createSystemTimerClock } from './clock.ts'; +export { createFakeTimerClock, type FakeTimerClock } from './fake-clock.ts'; export { computeBackoffDelay } from './backoff.ts'; export { createTimerDiagnostics, emitTimerDiagnostic } from './diagnostics.ts'; diff --git a/src/arts/timer/test/fake-clock.test.ts b/src/arts/timer/test/fake-clock.test.ts new file mode 100644 index 0000000..a7e9c2c --- /dev/null +++ b/src/arts/timer/test/fake-clock.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { createFakeTimerClock } from '../fake-clock.ts'; + +describe('createFakeTimerClock', () => { + it('starts at the configured time and advances on demand', async () => { + const clock = createFakeTimerClock(1000); + expect(clock.now()).toBe(1000); + + await clock.advanceBy(500); + expect(clock.now()).toBe(1500); + + await clock.advanceTo(2000); + expect(clock.now()).toBe(2000); + }); + + it('fires scheduled timeouts in due-order on advanceBy', async () => { + const clock = createFakeTimerClock(0); + const events: string[] = []; + + clock.setTimeout(() => events.push('B-200'), 200); + clock.setTimeout(() => events.push('A-100'), 100); + clock.setTimeout(() => events.push('C-300'), 300); + + await clock.advanceBy(250); + expect(events).toEqual(['A-100', 'B-200']); + + await clock.advanceBy(100); + expect(events).toEqual(['A-100', 'B-200', 'C-300']); + expect(clock.pendingCount()).toBe(0); + }); + + it('cancels timeouts via clearTimeout', async () => { + const clock = createFakeTimerClock(0); + let fired = false; + + const handle = clock.setTimeout(() => { + fired = true; + }, 100); + clock.clearTimeout(handle); + + await clock.advanceBy(200); + expect(fired).toBe(false); + expect(clock.pendingCount()).toBe(0); + }); +});