187 lines
6.2 KiB
TypeScript
187 lines
6.2 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
import { QUOTA_PAGE_SIZE } from '@/features/quota/constants';
|
|
import {
|
|
buildTabCounts,
|
|
classifyQuotaFiles,
|
|
filterEntriesByTab,
|
|
isQuotaRefreshDisabled,
|
|
paginate,
|
|
resolveQuotaProviderType,
|
|
sortQuotaEntries,
|
|
type QuotaFileEntry,
|
|
} from '@/features/quota/logic';
|
|
import type { AuthFileItem } from '@/types';
|
|
|
|
const file = (name: string, provider: string, extra: Partial<AuthFileItem> = {}): AuthFileItem =>
|
|
({ name, provider, ...extra }) as AuthFileItem;
|
|
|
|
const FILES: AuthFileItem[] = [
|
|
file('codex-a.json', 'codex'),
|
|
file('claude-a.json', 'claude'),
|
|
file('kimi-a.json', 'kimi'),
|
|
file('codex-b.json', 'codex'),
|
|
file('grok-a.json', 'grok'), // 别名归一到 xai
|
|
file('gemini-a.json', 'gemini'), // 不支持额度
|
|
file('claude-off.json', 'claude', { disabled: true }), // 停用
|
|
];
|
|
|
|
describe('resolveQuotaProviderType', () => {
|
|
test('maps provider aliases and rejects unsupported or disabled files', () => {
|
|
expect(resolveQuotaProviderType(file('a', 'grok'))).toBe('xai');
|
|
expect(resolveQuotaProviderType(file('a', 'antigravity'))).toBe('antigravity');
|
|
expect(resolveQuotaProviderType(file('a', 'gemini'))).toBeNull();
|
|
expect(resolveQuotaProviderType(file('a', 'claude', { disabled: true }))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('classifyQuotaFiles', () => {
|
|
test('drops unsupported and disabled files', () => {
|
|
const entries = classifyQuotaFiles(FILES);
|
|
expect(entries.map((entry) => entry.file.name)).not.toContain('gemini-a.json');
|
|
expect(entries.map((entry) => entry.file.name)).not.toContain('claude-off.json');
|
|
expect(entries).toHaveLength(5);
|
|
});
|
|
|
|
test('orders entries by provider tab order', () => {
|
|
const entries = classifyQuotaFiles(FILES);
|
|
expect(entries.map((entry) => entry.type)).toEqual(['claude', 'codex', 'codex', 'xai', 'kimi']);
|
|
});
|
|
});
|
|
|
|
describe('buildTabCounts', () => {
|
|
test('counts per provider plus an all total, zero-filling empty tabs', () => {
|
|
expect(buildTabCounts(classifyQuotaFiles(FILES))).toEqual({
|
|
all: 5,
|
|
claude: 1,
|
|
antigravity: 0,
|
|
codex: 2,
|
|
xai: 1,
|
|
kimi: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('filterEntriesByTab', () => {
|
|
const entries = classifyQuotaFiles(FILES);
|
|
|
|
test("passes everything through on the 'all' tab", () => {
|
|
expect(filterEntriesByTab(entries, 'all')).toHaveLength(5);
|
|
});
|
|
|
|
test('filters to a single provider', () => {
|
|
expect(filterEntriesByTab(entries, 'codex').map((entry) => entry.file.name)).toEqual([
|
|
'codex-a.json',
|
|
'codex-b.json',
|
|
]);
|
|
expect(filterEntriesByTab(entries, 'antigravity')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('isQuotaRefreshDisabled', () => {
|
|
test('blocks a single-card refresh while the same quota is resetting', () => {
|
|
expect(isQuotaRefreshDisabled(true, false, true)).toBe(true);
|
|
expect(isQuotaRefreshDisabled(true, false, false)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('paginate', () => {
|
|
const items = Array.from({ length: 45 }, (_, index) => index);
|
|
|
|
test('uses the configured 20-item page size', () => {
|
|
expect(QUOTA_PAGE_SIZE).toBe(20);
|
|
expect(paginate(items, 2, QUOTA_PAGE_SIZE)).toEqual({
|
|
pageItems: items.slice(20, 40),
|
|
currentPage: 2,
|
|
totalPages: 3,
|
|
});
|
|
});
|
|
|
|
test('clamps an out-of-range page instead of returning an empty slice', () => {
|
|
expect(paginate(items, 9, QUOTA_PAGE_SIZE).currentPage).toBe(3);
|
|
expect(paginate(items, 9, QUOTA_PAGE_SIZE).pageItems).toEqual(items.slice(40));
|
|
expect(paginate(items, 0, QUOTA_PAGE_SIZE).currentPage).toBe(1);
|
|
});
|
|
|
|
test('keeps at least one page when the list is empty', () => {
|
|
expect(paginate([], 1, QUOTA_PAGE_SIZE)).toEqual({
|
|
pageItems: [],
|
|
currentPage: 1,
|
|
totalPages: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('sortQuotaEntries', () => {
|
|
const entries = classifyQuotaFiles(FILES);
|
|
const byName = (list: QuotaFileEntry[]) => list.map((entry) => entry.file.name);
|
|
|
|
/** Recovery instants keyed by file name; anything absent resolves to null. */
|
|
const resolver = (instants: Record<string, number>) => (entry: QuotaFileEntry) =>
|
|
instants[entry.file.name] ?? null;
|
|
|
|
test('default mode preserves order but returns a new array', () => {
|
|
const sorted = sortQuotaEntries(entries, 'default', () => 1);
|
|
expect(byName(sorted)).toEqual(byName(entries));
|
|
expect(sorted).not.toBe(entries);
|
|
});
|
|
|
|
test('orders loaded credentials by how soon they recover, across providers', () => {
|
|
const sorted = sortQuotaEntries(
|
|
entries,
|
|
'soonest',
|
|
resolver({
|
|
'codex-a.json': 300,
|
|
'claude-a.json': 100,
|
|
'kimi-a.json': 200,
|
|
'codex-b.json': 400,
|
|
'grok-a.json': 50,
|
|
})
|
|
);
|
|
expect(byName(sorted)).toEqual([
|
|
'grok-a.json',
|
|
'claude-a.json',
|
|
'kimi-a.json',
|
|
'codex-a.json',
|
|
'codex-b.json',
|
|
]);
|
|
});
|
|
|
|
test('sinks credentials with no instant, keeping their provider-grouped order', () => {
|
|
// Loading is click-to-fetch, so an unloaded tail is the normal case.
|
|
const sorted = sortQuotaEntries(
|
|
entries,
|
|
'soonest',
|
|
resolver({ 'codex-b.json': 200, 'kimi-a.json': 100 })
|
|
);
|
|
expect(byName(sorted)).toEqual([
|
|
'kimi-a.json',
|
|
'codex-b.json',
|
|
// unresolved tail, in the order classifyQuotaFiles produced
|
|
'claude-a.json',
|
|
'codex-a.json',
|
|
'grok-a.json',
|
|
]);
|
|
});
|
|
|
|
test('leaves the order untouched when nothing has loaded', () => {
|
|
expect(byName(sortQuotaEntries(entries, 'soonest', () => null))).toEqual(byName(entries));
|
|
});
|
|
|
|
test('breaks ties on the original position, so equal instants stay stable', () => {
|
|
const sorted = sortQuotaEntries(entries, 'soonest', () => 500);
|
|
expect(byName(sorted)).toEqual(byName(entries));
|
|
});
|
|
|
|
test('does not mutate the input', () => {
|
|
const input = [...entries];
|
|
sortQuotaEntries(input, 'soonest', resolver({ 'codex-b.json': 1 }));
|
|
expect(input).toEqual(entries);
|
|
});
|
|
|
|
test('sorts before paginating, so the globally soonest lands on page one', () => {
|
|
// Last in the default order, first to recover.
|
|
const last = entries[entries.length - 1].file.name;
|
|
const sorted = sortQuotaEntries(entries, 'soonest', resolver({ [last]: 1 }));
|
|
expect(paginate(sorted, 1, 2).pageItems[0].file.name).toBe(last);
|
|
});
|
|
});
|