Add projects

This commit is contained in:
Alois 2026-08-24 00:10:41 +02:00
commit 8b607dd700
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
1802 changed files with 503346 additions and 2 deletions

View 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);
});
});