Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
187
frontend/tests/quotaPageLogic.test.ts
Normal file
187
frontend/tests/quotaPageLogic.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue