vibe-proxy/frontend/tests/sharedClock.test.ts
2026-08-24 00:10:41 +02:00

126 lines
3.5 KiB
TypeScript

/**
* Shared minute clock: snapshot stability and timer lifecycle.
*
* The stability case is the important one — a `getSnapshot` that returns a
* fresh `Date.now()` makes React's useSyncExternalStore re-render forever.
*/
import { describe, expect, test } from 'vitest';
import { createSharedClock } from '@/utils/time/sharedClock';
/** Deterministic stand-in for setInterval: fires only when the test says so. */
function makeFakeTimers() {
const timers = new Map<number, () => void>();
let nextId = 1;
let created = 0;
let cleared = 0;
return {
created: () => created,
cleared: () => cleared,
active: () => timers.size,
fireAll: () => timers.forEach((fn) => fn()),
setTimer: (fn: () => void) => {
created += 1;
const id = nextId++;
timers.set(id, fn);
return id;
},
clearTimer: (id: unknown) => {
cleared += 1;
timers.delete(id as number);
},
};
}
function makeClock(startAt = 1_000_000) {
const timers = makeFakeTimers();
let current = startAt;
const clock = createSharedClock({
intervalMs: 60_000,
now: () => current,
setTimer: timers.setTimer,
clearTimer: timers.clearTimer,
});
return { clock, timers, advance: (ms: number) => (current += ms) };
}
describe('createSharedClock', () => {
test('getSnapshot is referentially stable until a tick fires', () => {
const { clock, timers, advance } = makeClock();
clock.subscribe(() => {});
const first = clock.getSnapshot();
advance(30_000);
// Wall time moved but no tick fired — the snapshot must not.
expect(clock.getSnapshot()).toBe(first);
expect(clock.getSnapshot()).toBe(first);
timers.fireAll();
expect(clock.getSnapshot()).toBe(first + 30_000);
});
test('notifies every subscriber on a tick', () => {
const { clock, timers, advance } = makeClock();
let a = 0;
let b = 0;
clock.subscribe(() => (a += 1));
clock.subscribe(() => (b += 1));
advance(60_000);
timers.fireAll();
expect(a).toBe(1);
expect(b).toBe(1);
});
test('three subscribers share exactly one timer', () => {
const { clock, timers } = makeClock();
clock.subscribe(() => {});
clock.subscribe(() => {});
clock.subscribe(() => {});
expect(clock.subscriberCount()).toBe(3);
expect(timers.created()).toBe(1);
expect(timers.active()).toBe(1);
});
test('clears the timer when the last subscriber leaves, and restarts after', () => {
const { clock, timers } = makeClock();
const off1 = clock.subscribe(() => {});
const off2 = clock.subscribe(() => {});
off1();
expect(timers.active()).toBe(1); // still one listener
expect(timers.cleared()).toBe(0);
off2();
expect(clock.subscriberCount()).toBe(0);
expect(timers.active()).toBe(0);
expect(timers.cleared()).toBe(1);
clock.subscribe(() => {});
expect(timers.created()).toBe(2);
expect(timers.active()).toBe(1);
});
test('resynchronizes on the first subscribe so an idle clock is not stale', () => {
const { clock, timers, advance } = makeClock();
advance(3_600_000); // an hour passes with nobody watching
clock.subscribe(() => {});
expect(clock.getSnapshot()).toBe(1_000_000 + 3_600_000);
expect(timers.created()).toBe(1);
});
test('unsubscribing twice does not clear a fresh timer', () => {
const { clock, timers } = makeClock();
const off = clock.subscribe(() => {});
off();
off();
clock.subscribe(() => {});
expect(timers.cleared()).toBe(1);
expect(timers.active()).toBe(1);
});
});