The `engine-timers.test.ts` file already had a `createFakeClock` for internal use, but every artifact under test that accepts a `clock` injection (storage, format, logger, http, session auto-refresh, …) was rolling its own. Promoting the helper to the public surface gives the ecosystem a single source of deterministic time for tests. `createFakeTimerClock(start = 0): FakeTimerClock` exposes `advanceBy(ms)`, `advanceTo(ms)` and `pendingCount()` on top of the shared `TimerClock` shape. Microtask flushes between fired entries keep awaited promises inside scheduled callbacks resolved before time moves on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>master
parent
5152b23c6b
commit
0c15e0b94c
@ -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<void>;
|
||||
advanceTo(ms: number): Promise<void>;
|
||||
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<number, FakeClockEntry>();
|
||||
let nextId = 1;
|
||||
|
||||
function fire(entry: FakeClockEntry): void {
|
||||
if (entry.cancelled) return;
|
||||
queue.delete(entry.id);
|
||||
entry.fn();
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
if (target <= now) return;
|
||||
await clock.advanceBy(target - now);
|
||||
},
|
||||
pendingCount: () => queue.size
|
||||
};
|
||||
return clock;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue