239 lines
8.1 KiB
TypeScript
239 lines
8.1 KiB
TypeScript
/**
|
|
* Which row on a quota card recovers first, per provider.
|
|
*/
|
|
|
|
import { describe, expect, test } from 'vitest';
|
|
import {
|
|
XAI_WEEKLY_ROW_ID,
|
|
collectQuotaRowInstants,
|
|
nextRecoveryMs,
|
|
pickSoonestRowId,
|
|
pickUrgentRowId,
|
|
resetCreditRowId,
|
|
} from '@/features/quota/resetSchedule';
|
|
import { DAY_MS, HOUR_MS } from '@/utils/time/durations';
|
|
|
|
const NOW = new Date(2026, 7, 2, 12).getTime();
|
|
const iso = (ms: number) => new Date(ms).toISOString();
|
|
|
|
const claudeQuota = {
|
|
status: 'success',
|
|
windows: [
|
|
{ id: 'five_hour', resetAtMs: NOW + 3 * HOUR_MS },
|
|
{ id: 'seven_day', resetAtMs: NOW + 4 * DAY_MS },
|
|
],
|
|
};
|
|
|
|
const codexQuota = {
|
|
status: 'success',
|
|
windows: [
|
|
{ id: 'primary', resetAtMs: NOW + 3 * HOUR_MS },
|
|
{ id: 'secondary', resetAtMs: NOW + 6 * DAY_MS },
|
|
],
|
|
rateLimitResetCredits: [
|
|
{ id: 'credit-a', status: 'available', expiresAt: iso(NOW + 11 * DAY_MS) },
|
|
{ id: 'credit-b', status: 'available', expiresAt: iso(NOW + 2 * DAY_MS) },
|
|
],
|
|
};
|
|
|
|
describe('collectQuotaRowInstants', () => {
|
|
test('collects every Claude window', () => {
|
|
expect(collectQuotaRowInstants('claude', claudeQuota)).toEqual([
|
|
{ rowId: 'five_hour', atMs: NOW + 3 * HOUR_MS, kind: 'window' },
|
|
{ rowId: 'seven_day', atMs: NOW + 4 * DAY_MS, kind: 'window' },
|
|
]);
|
|
});
|
|
|
|
test('collects Codex windows and available reset credits together', () => {
|
|
const instants = collectQuotaRowInstants('codex', codexQuota);
|
|
expect(instants).toHaveLength(4);
|
|
expect(instants.filter((i) => i.kind === 'credit').map((i) => i.rowId)).toEqual([
|
|
'credit-a',
|
|
'credit-b',
|
|
]);
|
|
});
|
|
|
|
test('ignores reset credits that are not available', () => {
|
|
const consumed = {
|
|
...codexQuota,
|
|
rateLimitResetCredits: [
|
|
{ id: 'used', status: 'consumed', expiresAt: iso(NOW + HOUR_MS) },
|
|
{ id: 'live', status: 'available', expiresAt: iso(NOW + 2 * HOUR_MS) },
|
|
],
|
|
};
|
|
expect(
|
|
collectQuotaRowInstants('codex', consumed)
|
|
.filter((i) => i.kind === 'credit')
|
|
.map((i) => i.rowId)
|
|
).toEqual(['live']);
|
|
});
|
|
|
|
test('collects the xAI weekly window', () => {
|
|
const quota = { status: 'success', billing: { periodType: 'weekly', resetAtMs: NOW + DAY_MS } };
|
|
expect(collectQuotaRowInstants('xai', quota)).toEqual([
|
|
{ rowId: XAI_WEEKLY_ROW_ID, atMs: NOW + DAY_MS, kind: 'window' },
|
|
]);
|
|
});
|
|
|
|
test('ignores an xAI monthly summary — a billing cycle is not capacity returning', () => {
|
|
const quota = {
|
|
status: 'success',
|
|
billing: { periodType: 'monthly', resetAtMs: NOW + DAY_MS },
|
|
};
|
|
expect(collectQuotaRowInstants('xai', quota)).toEqual([]);
|
|
});
|
|
|
|
test('flattens Antigravity buckets across groups', () => {
|
|
const quota = {
|
|
status: 'success',
|
|
groups: [
|
|
{ id: 'g1', buckets: [{ id: 'b1', resetAtMs: NOW + HOUR_MS }] },
|
|
{ id: 'g2', buckets: [{ id: 'b2', resetAtMs: NOW + 2 * HOUR_MS }] },
|
|
],
|
|
};
|
|
expect(collectQuotaRowInstants('antigravity', quota).map((i) => i.rowId)).toEqual(['b1', 'b2']);
|
|
});
|
|
|
|
test('collects Kimi rows', () => {
|
|
const quota = { status: 'success', rows: [{ id: 'r1', resetAtMs: NOW + HOUR_MS }] };
|
|
expect(collectQuotaRowInstants('kimi', quota).map((i) => i.rowId)).toEqual(['r1']);
|
|
});
|
|
|
|
test('returns nothing unless the credential loaded successfully', () => {
|
|
for (const status of ['idle', 'loading', 'error']) {
|
|
expect(collectQuotaRowInstants('claude', { ...claudeQuota, status })).toEqual([]);
|
|
}
|
|
expect(collectQuotaRowInstants('claude', undefined)).toEqual([]);
|
|
});
|
|
|
|
test('drops rows with no usable instant rather than emitting NaN', () => {
|
|
const quota = {
|
|
status: 'success',
|
|
windows: [
|
|
{ id: 'ok', resetAtMs: NOW + HOUR_MS },
|
|
{ id: 'missing' },
|
|
{ id: 'null', resetAtMs: null },
|
|
{ id: 'nan', resetAtMs: Number.NaN },
|
|
],
|
|
};
|
|
expect(collectQuotaRowInstants('claude', quota).map((i) => i.rowId)).toEqual(['ok']);
|
|
});
|
|
|
|
test('drops a reset credit whose expiry will not parse', () => {
|
|
const quota = {
|
|
status: 'success',
|
|
windows: [],
|
|
rateLimitResetCredits: [{ id: 'bad', status: 'available', expiresAt: 'not a date' }],
|
|
};
|
|
expect(collectQuotaRowInstants('codex', quota)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('pickSoonestRowId', () => {
|
|
test('picks the nearest upcoming instant', () => {
|
|
expect(pickSoonestRowId(collectQuotaRowInstants('claude', claudeQuota), NOW)).toBe('five_hour');
|
|
});
|
|
|
|
test('a credit expiring before every window wins the emphasis', () => {
|
|
const quota = {
|
|
...codexQuota,
|
|
windows: [{ id: 'primary', resetAtMs: NOW + 5 * DAY_MS }],
|
|
};
|
|
expect(pickSoonestRowId(collectQuotaRowInstants('codex', quota), NOW)).toBe('credit-b');
|
|
});
|
|
|
|
test('a window resetting before every credit wins the emphasis', () => {
|
|
expect(pickSoonestRowId(collectQuotaRowInstants('codex', codexQuota), NOW)).toBe('primary');
|
|
});
|
|
|
|
test('skips instants that have already passed', () => {
|
|
const instants = [
|
|
{ rowId: 'past', atMs: NOW - HOUR_MS, kind: 'window' as const },
|
|
{ rowId: 'exactly-now', atMs: NOW, kind: 'window' as const },
|
|
{ rowId: 'future', atMs: NOW + HOUR_MS, kind: 'window' as const },
|
|
];
|
|
expect(pickSoonestRowId(instants, NOW)).toBe('future');
|
|
});
|
|
|
|
test('returns null when nothing is pending', () => {
|
|
expect(pickSoonestRowId([], NOW)).toBeNull();
|
|
expect(pickSoonestRowId([{ rowId: 'past', atMs: NOW - 1, kind: 'window' }], NOW)).toBeNull();
|
|
});
|
|
|
|
test('breaks ties deterministically on row id', () => {
|
|
const a = [
|
|
{ rowId: 'b', atMs: NOW + HOUR_MS, kind: 'window' as const },
|
|
{ rowId: 'a', atMs: NOW + HOUR_MS, kind: 'window' as const },
|
|
];
|
|
expect(pickSoonestRowId(a, NOW)).toBe('a');
|
|
expect(pickSoonestRowId([...a].reverse(), NOW)).toBe('a');
|
|
});
|
|
});
|
|
|
|
describe('pickUrgentRowId', () => {
|
|
test('highlights only the nearest reset strictly inside the final hour', () => {
|
|
const instants = [
|
|
{ rowId: 'later-urgent', atMs: NOW + 45 * 60_000, kind: 'window' as const },
|
|
{ rowId: 'nearest-urgent', atMs: NOW + 30 * 60_000, kind: 'window' as const },
|
|
{ rowId: 'past', atMs: NOW - 1, kind: 'window' as const },
|
|
];
|
|
|
|
expect(pickUrgentRowId(instants, NOW)).toBe('nearest-urgent');
|
|
});
|
|
|
|
test('does not highlight at exactly one hour or beyond', () => {
|
|
expect(
|
|
pickUrgentRowId(
|
|
[
|
|
{ rowId: 'exactly-one-hour', atMs: NOW + HOUR_MS, kind: 'window' },
|
|
{ rowId: 'later', atMs: NOW + 2 * HOUR_MS, kind: 'window' },
|
|
],
|
|
NOW
|
|
)
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resetCreditRowId', () => {
|
|
test('prefers the credit id', () => {
|
|
expect(resetCreditRowId({ id: 'credit-a', expiresAt: 'x' }, 3)).toBe('credit-a');
|
|
});
|
|
|
|
test('falls back to expiry and index when the payload carries no id', () => {
|
|
// Must stay byte-identical to CodexQuotaBody's React key.
|
|
expect(resetCreditRowId({ id: '', expiresAt: '2026-08-13T00:00:00Z' }, 2)).toBe(
|
|
'2026-08-13T00:00:00Z-2'
|
|
);
|
|
expect(resetCreditRowId({ expiresAt: '2026-08-13T00:00:00Z' }, 0)).toBe(
|
|
'2026-08-13T00:00:00Z-0'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('nextRecoveryMs', () => {
|
|
test('returns the soonest upcoming instant across windows and credits', () => {
|
|
expect(nextRecoveryMs('codex', codexQuota, NOW)).toBe(NOW + 3 * HOUR_MS);
|
|
});
|
|
|
|
test('ignores instants already in the past', () => {
|
|
const quota = {
|
|
status: 'success',
|
|
windows: [
|
|
{ id: 'stale', resetAtMs: NOW - DAY_MS },
|
|
{ id: 'live', resetAtMs: NOW + DAY_MS },
|
|
],
|
|
};
|
|
expect(nextRecoveryMs('claude', quota, NOW)).toBe(NOW + DAY_MS);
|
|
});
|
|
|
|
test('is null for an unloaded credential, so sorting can sink it', () => {
|
|
expect(nextRecoveryMs('claude', undefined, NOW)).toBeNull();
|
|
expect(nextRecoveryMs('claude', { status: 'idle' }, NOW)).toBeNull();
|
|
expect(nextRecoveryMs('claude', { status: 'error' }, NOW)).toBeNull();
|
|
});
|
|
|
|
test('is null when every known instant has passed', () => {
|
|
const quota = { status: 'success', windows: [{ id: 'stale', resetAtMs: NOW - 1 }] };
|
|
expect(nextRecoveryMs('claude', quota, NOW)).toBeNull();
|
|
});
|
|
});
|