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.
120 lines
5.1 KiB
TypeScript
120 lines
5.1 KiB
TypeScript
/**
|
|
* Relative-time formatting for quota cards.
|
|
*/
|
|
|
|
import { describe, expect, test } from 'bun:test';
|
|
import {
|
|
buildResetDisplay,
|
|
formatInstantShort,
|
|
formatQuotaResetTime,
|
|
formatRelativeInstant,
|
|
relativeTimeParts,
|
|
} from '@/utils/quota';
|
|
import { DAY_MS, HOUR_MS, MINUTE_MS } from '@/utils/time/durations';
|
|
|
|
const NOW = new Date(2026, 7, 2, 12, 0, 0).getTime();
|
|
|
|
describe('relativeTimeParts', () => {
|
|
test('picks the coarsest unit that still describes the gap', () => {
|
|
expect(relativeTimeParts(NOW + DAY_MS, NOW)).toEqual({ value: 1, unit: 'day' });
|
|
expect(relativeTimeParts(NOW + DAY_MS - 1, NOW)).toEqual({ value: 24, unit: 'hour' });
|
|
expect(relativeTimeParts(NOW + HOUR_MS, NOW)).toEqual({ value: 1, unit: 'hour' });
|
|
expect(relativeTimeParts(NOW + HOUR_MS - 1, NOW)).toEqual({ value: 60, unit: 'minute' });
|
|
expect(relativeTimeParts(NOW + 30_000, NOW)).toEqual({ value: 1, unit: 'minute' });
|
|
});
|
|
|
|
test('rounds up, so a partial unit never reads as fewer', () => {
|
|
expect(relativeTimeParts(NOW + 11 * DAY_MS + 1, NOW)).toEqual({ value: 12, unit: 'day' });
|
|
expect(relativeTimeParts(NOW + 90 * MINUTE_MS, NOW)).toEqual({ value: 2, unit: 'hour' });
|
|
});
|
|
|
|
test('past instants are negative rather than clamped to zero', () => {
|
|
// The timeline-local predecessor clamped with Math.max(0, …), so an expired
|
|
// credit read "in 1 minute".
|
|
expect(relativeTimeParts(NOW - 3 * DAY_MS, NOW)).toEqual({ value: -3, unit: 'day' });
|
|
expect(relativeTimeParts(NOW - 2 * HOUR_MS, NOW)).toEqual({ value: -2, unit: 'hour' });
|
|
});
|
|
|
|
test('sub-minute magnitudes floor to 1 in both directions', () => {
|
|
expect(relativeTimeParts(NOW + 1, NOW)).toEqual({ value: 1, unit: 'minute' });
|
|
expect(relativeTimeParts(NOW, NOW)).toEqual({ value: 1, unit: 'minute' });
|
|
expect(relativeTimeParts(NOW - 1, NOW)).toEqual({ value: -1, unit: 'minute' });
|
|
});
|
|
});
|
|
|
|
describe('formatRelativeInstant', () => {
|
|
test('renders in the requested locale', () => {
|
|
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'en')).toContain('days');
|
|
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'zh-CN')).toContain('天');
|
|
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'zh-TW')).toContain('天');
|
|
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'ru')).toBeTruthy();
|
|
});
|
|
|
|
test('distinguishes past from future', () => {
|
|
const future = formatRelativeInstant(NOW + 3 * DAY_MS, NOW, 'en');
|
|
const past = formatRelativeInstant(NOW - 3 * DAY_MS, NOW, 'en');
|
|
expect(future).not.toBe(past);
|
|
expect(past).toContain('ago');
|
|
});
|
|
|
|
test('falls back instead of throwing on an unusable locale tag', () => {
|
|
expect(() => formatRelativeInstant(NOW + DAY_MS, NOW, 'not-a-locale!!')).not.toThrow();
|
|
expect(formatRelativeInstant(NOW + DAY_MS, NOW, 'not-a-locale!!')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('formatInstantShort', () => {
|
|
test('matches the shape the baked reset labels already use', () => {
|
|
const iso = new Date(2026, 7, 13, 14, 30).toISOString();
|
|
expect(formatInstantShort(new Date(iso).getTime())).toBe(formatQuotaResetTime(iso));
|
|
});
|
|
|
|
test('degrades to a dash rather than "Invalid Date"', () => {
|
|
expect(formatInstantShort(Number.NaN)).toBe('-');
|
|
});
|
|
});
|
|
|
|
describe('buildResetDisplay', () => {
|
|
test('pairs a baked absolute label with a computed relative one', () => {
|
|
const display = buildResetDisplay('08-13 14:30', NOW + 11 * DAY_MS, NOW, 'en');
|
|
expect(display).not.toBeNull();
|
|
expect(display?.absolute).toBe('08-13 14:30');
|
|
expect(display?.relative).toContain('11 days');
|
|
});
|
|
|
|
test('keeps the baked label alone when the instant is missing', () => {
|
|
// A store entry cached by an older build carries resetLabel but no resetAtMs.
|
|
expect(buildResetDisplay('08-13 14:30', null, NOW, 'en')).toEqual({
|
|
absolute: '08-13 14:30',
|
|
relative: null,
|
|
});
|
|
expect(buildResetDisplay('08-13 14:30', undefined, NOW, 'en')?.relative).toBeNull();
|
|
});
|
|
|
|
test('derives the absolute half from the instant when no label was baked', () => {
|
|
const at = NOW + 2 * HOUR_MS;
|
|
const display = buildResetDisplay(undefined, at, NOW, 'en');
|
|
expect(display?.absolute).toBe(formatInstantShort(at));
|
|
expect(display?.relative).toContain('2 hours');
|
|
});
|
|
|
|
test('returns null when there is nothing to render', () => {
|
|
expect(buildResetDisplay(undefined, null, NOW, 'en')).toBeNull();
|
|
expect(buildResetDisplay('', null, NOW, 'en')).toBeNull();
|
|
expect(buildResetDisplay(' ', null, NOW, 'en')).toBeNull();
|
|
// '-' is the providers' own placeholder for "no reset known".
|
|
expect(buildResetDisplay('-', null, NOW, 'en')).toBeNull();
|
|
});
|
|
|
|
test('treats a placeholder label with a real instant as renderable', () => {
|
|
const display = buildResetDisplay('-', NOW + DAY_MS, NOW, 'en');
|
|
expect(display?.absolute).toBe(formatInstantShort(NOW + DAY_MS));
|
|
expect(display?.relative).toContain('1 day');
|
|
});
|
|
|
|
test('rejects a non-finite instant', () => {
|
|
expect(buildResetDisplay(undefined, Number.NaN, NOW, 'en')).toBeNull();
|
|
expect(buildResetDisplay('08-13 14:30', Number.POSITIVE_INFINITY, NOW, 'en')?.relative).toBeNull();
|
|
});
|
|
});
|