mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-28 16:31:13 +08:00
ddabc99e24
Relative time goes stale on its own — nothing re-renders when a minute passes, so a long-lived tab silently lies. The naive fix is a timer per component, which on the quota page means one per card body. Adds one app-wide clock instead, started on the first subscriber and cleared on the last, exposed through useNow(). QuotaTimeline drops its private interval and joins it, so the chart and the cards above it tick in lockstep. Also extracts the timeline's private relative-time formatter into a shared, testable one. It is now signed: the old version clamped with Math.max(0, …), so a credit that expired last week read "in 1 minute". Duration constants and the MM-DD HH:mm shape are deduplicated into utils/time/durations.ts and formatInstantShort, which the existing formatQuotaResetTime / formatUnixSeconds now delegate to.
127 lines
3.5 KiB
TypeScript
127 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 'bun:test';
|
|
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);
|
|
});
|
|
});
|