diff --git a/package.json b/package.json index 5b506caa..359fdc44 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.12.1", + "version": "7.12.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 71cc2d3e..8ba64cd1 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -12,6 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { CLIProxyProvider } from './types'; import { getCliproxyDir, getAuthDir } from './config-generator'; +import { PROVIDER_TYPE_VALUES } from './auth/auth-types'; /** Account information */ export interface AccountInfo { @@ -431,26 +432,33 @@ export function discoverExistingAccounts(): void { // Skip if no type field if (!data.type) continue; - // Map token type values to internal provider names - // CLIProxyAPI uses different type values in tokens (e.g., "antigravity" vs "agy") - const typeToProvider: Record = { - gemini: 'gemini', - antigravity: 'agy', - codex: 'codex', - qwen: 'qwen', - iflow: 'iflow', - }; - + // Build reverse mapping from PROVIDER_TYPE_VALUES (type value -> provider) + // e.g., "antigravity" -> "agy", "kiro" -> "kiro", "codewhisperer" -> "kiro" const typeValue = data.type.toLowerCase(); - const provider = typeToProvider[typeValue]; + let provider: CLIProxyProvider | undefined; + for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) { + if (typeValues.includes(typeValue)) { + provider = prov as CLIProxyProvider; + break; + } + } // Skip if unknown provider type if (!provider) { continue; } - // Extract email if available - const email = data.email || undefined; + // Extract email if available, fallback to filename + let email = data.email || undefined; + + // Fallback: extract email from filename (e.g., "kiro-google-user@example.com.json") + if (!email && file.includes('@')) { + const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/); + if (match) { + email = match[1]; + } + } + const accountId = email || 'default'; // Initialize provider section if needed diff --git a/tests/unit/cliproxy/account-manager-discover.test.js b/tests/unit/cliproxy/account-manager-discover.test.js new file mode 100644 index 00000000..ae3cf72a --- /dev/null +++ b/tests/unit/cliproxy/account-manager-discover.test.js @@ -0,0 +1,352 @@ +/** + * Account Manager Discovery Tests + * + * Tests for discoverExistingAccounts() function including: + * - Kiro/GHCP provider mapping + * - Email extraction from filename + * - Multiple accounts per provider + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +describe('Account Manager - discoverExistingAccounts', () => { + let testDir; + let originalCcsHome; + let accountManager; + + beforeEach(() => { + // Create temp directory for test + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-')); + const ccsDir = path.join(testDir, '.ccs', 'cliproxy'); + const authDir = path.join(ccsDir, 'auth'); + fs.mkdirSync(authDir, { recursive: true }); + + // Mock CCS_HOME to use temp directory + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = testDir; + + // Clear cache and reload + delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')]; + delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')]; + delete require.cache[require.resolve('../../../dist/utils/config-manager')]; + accountManager = require('../../../dist/cliproxy/account-manager'); + }); + + afterEach(() => { + // Restore CCS_HOME + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + // Cleanup temp directory + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + function createAuthFile(filename, content) { + const authDir = path.join(testDir, '.ccs', 'cliproxy', 'auth'); + fs.writeFileSync(path.join(authDir, filename), JSON.stringify(content)); + } + + function getAccountsFile() { + const accountsPath = path.join(testDir, '.ccs', 'cliproxy', 'accounts.json'); + if (fs.existsSync(accountsPath)) { + return JSON.parse(fs.readFileSync(accountsPath, 'utf-8')); + } + return null; + } + + // ========================================================================= + // Provider Type Mapping Tests + // ========================================================================= + describe('Provider Type Mapping', () => { + it('discovers kiro accounts with type="kiro"', () => { + createAuthFile('kiro-google-user@example.com.json', { + type: 'kiro', + email: '', + provider: 'Google', + profile_arn: 'arn:aws:codewhisperer:us-east-1::profile/123', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + assert(accounts, 'accounts.json should be created'); + assert(accounts.providers.kiro, 'kiro provider should exist'); + assert.strictEqual( + Object.keys(accounts.providers.kiro.accounts).length, + 1, + 'Should have 1 kiro account' + ); + }); + + it('discovers kiro accounts with type="codewhisperer"', () => { + createAuthFile('codewhisperer-user@example.com.json', { + type: 'codewhisperer', + email: 'user@example.com', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + assert(accounts.providers.kiro, 'codewhisperer type should map to kiro provider'); + }); + + it('discovers ghcp accounts with type="github-copilot"', () => { + createAuthFile('ghcp-user@example.com.json', { + type: 'github-copilot', + email: 'user@example.com', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + assert(accounts.providers.ghcp, 'github-copilot type should map to ghcp provider'); + }); + + it('discovers ghcp accounts with type="copilot"', () => { + createAuthFile('copilot-user@example.com.json', { + type: 'copilot', + email: 'user@example.com', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + assert(accounts.providers.ghcp, 'copilot type should map to ghcp provider'); + }); + + it('handles case-insensitive type values', () => { + createAuthFile('kiro-test.json', { + type: 'KIRO', + email: 'test@example.com', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + assert(accounts.providers.kiro, 'KIRO (uppercase) should map to kiro provider'); + }); + }); + + // ========================================================================= + // Email Extraction Tests + // ========================================================================= + describe('Email Extraction from Filename', () => { + it('extracts email from filename when data.email is empty', () => { + createAuthFile('kiro-google-user@example.com.json', { + type: 'kiro', + email: '', + provider: 'Google', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + const accountIds = Object.keys(accounts.providers.kiro.accounts); + assert.strictEqual(accountIds[0], 'user@example.com', 'Should extract email from filename'); + }); + + it('uses data.email when available', () => { + createAuthFile('kiro-test.json', { + type: 'kiro', + email: 'actual@email.com', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + const accountIds = Object.keys(accounts.providers.kiro.accounts); + assert.strictEqual(accountIds[0], 'actual@email.com', 'Should use data.email when available'); + }); + + it('falls back to "default" when no email in file or filename', () => { + createAuthFile('kiro-nomail.json', { + type: 'kiro', + email: '', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + const accountIds = Object.keys(accounts.providers.kiro.accounts); + assert.strictEqual(accountIds[0], 'default', 'Should fall back to "default"'); + }); + + it('handles dots in email local part', () => { + createAuthFile('kiro-google-first.last@example.com.json', { + type: 'kiro', + email: '', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + const accountIds = Object.keys(accounts.providers.kiro.accounts); + assert.strictEqual( + accountIds[0], + 'first.last@example.com', + 'Should handle dots in local part' + ); + }); + + it('handles plus addressing in email', () => { + createAuthFile('kiro-google-user+tag@example.com.json', { + type: 'kiro', + email: '', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + const accountIds = Object.keys(accounts.providers.kiro.accounts); + assert.strictEqual(accountIds[0], 'user+tag@example.com', 'Should handle plus addressing'); + }); + }); + + // ========================================================================= + // Multiple Accounts Tests + // ========================================================================= + describe('Multiple Accounts', () => { + it('discovers multiple kiro accounts', () => { + createAuthFile('kiro-google-user1@example.com.json', { + type: 'kiro', + email: '', + }); + createAuthFile('kiro-google-user2@example.com.json', { + type: 'kiro', + email: '', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + assert.strictEqual( + Object.keys(accounts.providers.kiro.accounts).length, + 2, + 'Should discover 2 kiro accounts' + ); + }); + + it('does not overwrite existing accounts', () => { + // Create first account + createAuthFile('kiro-google-user1@example.com.json', { + type: 'kiro', + email: '', + }); + accountManager.discoverExistingAccounts(); + + // Add another file and run again + createAuthFile('kiro-google-user2@example.com.json', { + type: 'kiro', + email: '', + }); + + // Reload module to clear in-memory cache + delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')]; + accountManager = require('../../../dist/cliproxy/account-manager'); + accountManager.discoverExistingAccounts(); + + const accounts = getAccountsFile(); + assert.strictEqual( + Object.keys(accounts.providers.kiro.accounts).length, + 2, + 'Should have 2 accounts without overwriting' + ); + }); + + it('sets first discovered account as default', () => { + createAuthFile('kiro-google-user@example.com.json', { + type: 'kiro', + email: '', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + assert.strictEqual( + accounts.providers.kiro.default, + 'user@example.com', + 'First account should be default' + ); + }); + }); + + // ========================================================================= + // Edge Cases + // ========================================================================= + describe('Edge Cases', () => { + it('skips files without type field', () => { + createAuthFile('invalid.json', { + email: 'test@example.com', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + // Should either be null or have no providers + if (accounts) { + assert.strictEqual(Object.keys(accounts.providers).length, 0, 'Should have no providers'); + } + }); + + it('skips files with unknown type', () => { + createAuthFile('unknown.json', { + type: 'unknown-provider', + email: 'test@example.com', + }); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + if (accounts) { + assert.strictEqual( + Object.keys(accounts.providers).length, + 0, + 'Should skip unknown providers' + ); + } + }); + + it('skips non-JSON files', () => { + fs.writeFileSync( + path.join(testDir, '.ccs', 'cliproxy', 'auth', 'readme.txt'), + 'This is not JSON' + ); + + accountManager.discoverExistingAccounts(); + const accounts = getAccountsFile(); + + // Should not throw, should skip the file + if (accounts) { + assert.strictEqual(Object.keys(accounts.providers).length, 0); + } + }); + + it('handles malformed JSON gracefully', () => { + fs.writeFileSync( + path.join(testDir, '.ccs', 'cliproxy', 'auth', 'bad.json'), + '{not valid json' + ); + + // Should not throw + assert.doesNotThrow(() => { + accountManager.discoverExistingAccounts(); + }); + }); + + it('handles missing auth directory', () => { + // Remove auth directory + fs.rmSync(path.join(testDir, '.ccs', 'cliproxy', 'auth'), { recursive: true }); + + // Should not throw + assert.doesNotThrow(() => { + accountManager.discoverExistingAccounts(); + }); + }); + }); +}); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 25e8b68a..1b07c358 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -2,7 +2,13 @@ * Account Card Component for Flow Visualization */ -import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils'; +import { + cn, + sortModelsByPriority, + formatResetTime, + getEarliestResetTime, + getMinClaudeQuota, +} from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { GripVertical, Loader2, Clock } from 'lucide-react'; import { useAccountQuota } from '@/hooks/use-cliproxy-stats'; @@ -83,10 +89,8 @@ export function AccountCard({ account.id, isAgy ); - const avgQuota = - quota?.success && quota.models.length > 0 - ? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length) - : null; + // Show minimum quota of Claude models (primary), fallback to min of all models + const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null; return (
Quota...
- ) : avgQuota !== null ? ( + ) : minQuota !== null ? ( @@ -148,27 +152,27 @@ export function AccountCard({ 50 + minQuota > 50 ? 'text-emerald-600 dark:text-emerald-400' - : avgQuota > 20 + : minQuota > 20 ? 'text-amber-500' : 'text-red-500' )} > - {avgQuota}% + {minQuota}%
50 + minQuota > 50 ? 'bg-emerald-500' - : avgQuota > 20 + : minQuota > 20 ? 'bg-amber-500' : 'bg-red-500' )} - style={{ width: `${avgQuota}%` }} + style={{ width: `${minQuota}%` }} />
diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 98c9eef9..0ef2c276 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -23,7 +23,13 @@ import { CheckCircle2, HelpCircle, } from 'lucide-react'; -import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils'; +import { + cn, + sortModelsByPriority, + formatResetTime, + getEarliestResetTime, + getMinClaudeQuota, +} from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import type { AccountItemProps } from './types'; @@ -98,11 +104,8 @@ export function AccountItem({ const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt; const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed); - // Calculate average quota across all models - const avgQuota = - quota?.success && quota.models.length > 0 - ? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length) - : null; + // Show minimum quota of Claude models (primary), fallback to min of all models + const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null; // Get earliest reset time const nextReset = @@ -179,7 +182,7 @@ export function AccountItem({ Loading quota... - ) : avgQuota !== null ? ( + ) : minQuota !== null ? (
{/* Status indicator based on runtime usage, not file state */}
@@ -210,11 +213,11 @@ export function AccountItem({
- {avgQuota}% + {minQuota}%
diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx index ab895f8e..45811bfd 100644 --- a/ui/src/components/ui/badge.tsx +++ b/ui/src/components/ui/badge.tsx @@ -23,7 +23,8 @@ const badgeVariants = cva( ); interface BadgeProps - extends React.HTMLAttributes, VariantProps {} + extends React.HTMLAttributes, + VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { return
; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 2e1a70ee..104ebf3c 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -116,3 +116,27 @@ export function getEarliestResetTime( null as string | null ); } + +/** + * Calculate the minimum quota percentage from Claude models (primary usage). + * Falls back to minimum of all models if no Claude models exist. + * Returns null if no valid models or quota data. + */ +export function getMinClaudeQuota< + T extends { name: string; displayName?: string; percentage: number }, +>(models: T[]): number | null { + if (models.length === 0) return null; + + const claudeModels = models.filter((m) => { + const name = (m.displayName || m.name || '').toLowerCase(); + return name.includes('claude'); + }); + + const targetModels = claudeModels.length > 0 ? claudeModels : models; + const percentages = targetModels + .map((m) => m.percentage) + .filter((p) => typeof p === 'number' && isFinite(p)); + + if (percentages.length === 0) return null; + return Math.min(...percentages); +} diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts new file mode 100644 index 00000000..af30823d --- /dev/null +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for quota utility functions + */ + +import { describe, it, expect } from 'vitest'; +import { getMinClaudeQuota, sortModelsByPriority, getEarliestResetTime } from '@/lib/utils'; + +describe('getMinClaudeQuota', () => { + describe('basic functionality', () => { + it('returns null for empty models array', () => { + expect(getMinClaudeQuota([])).toBeNull(); + }); + + it('returns minimum of Claude models when present', () => { + const models = [ + { name: 'claude-opus-4', displayName: 'Claude Opus 4', percentage: 95 }, + { name: 'claude-sonnet-4', displayName: 'Claude Sonnet 4', percentage: 90 }, + { name: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', percentage: 100 }, + ]; + expect(getMinClaudeQuota(models)).toBe(90); + }); + + it('falls back to minimum of all models when no Claude models', () => { + const models = [ + { name: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', percentage: 100 }, + { name: 'gemini-3-pro', displayName: 'Gemini 3 Pro', percentage: 98 }, + ]; + expect(getMinClaudeQuota(models)).toBe(98); + }); + + it('handles single Claude model', () => { + const models = [{ name: 'claude-opus-4', displayName: 'Claude Opus 4', percentage: 85 }]; + expect(getMinClaudeQuota(models)).toBe(85); + }); + }); + + describe('Claude detection', () => { + it('detects Claude from displayName', () => { + const models = [ + { name: 'model-abc', displayName: 'Claude Opus 4.5', percentage: 75 }, + { name: 'gemini-flash', displayName: 'Gemini Flash', percentage: 100 }, + ]; + expect(getMinClaudeQuota(models)).toBe(75); + }); + + it('detects Claude from name when displayName is missing', () => { + const models = [ + { name: 'claude-sonnet-4-thinking', percentage: 80 }, + { name: 'gemini-2.5-flash', percentage: 100 }, + ]; + expect(getMinClaudeQuota(models)).toBe(80); + }); + + it('is case-insensitive for Claude detection', () => { + const models = [ + { name: 'CLAUDE-OPUS', displayName: 'CLAUDE OPUS', percentage: 70 }, + { name: 'gemini', displayName: 'Gemini', percentage: 100 }, + ]; + expect(getMinClaudeQuota(models)).toBe(70); + }); + }); + + describe('edge cases', () => { + it('handles models with 0% quota', () => { + const models = [ + { name: 'claude-opus', displayName: 'Claude Opus', percentage: 0 }, + { name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: 50 }, + ]; + expect(getMinClaudeQuota(models)).toBe(0); + }); + + it('handles models with 100% quota', () => { + const models = [{ name: 'claude-opus', displayName: 'Claude Opus', percentage: 100 }]; + expect(getMinClaudeQuota(models)).toBe(100); + }); + + it('handles undefined displayName gracefully', () => { + const models = [{ name: 'claude-opus', displayName: undefined, percentage: 85 }]; + expect(getMinClaudeQuota(models)).toBe(85); + }); + + it('handles empty string displayName', () => { + const models = [{ name: 'claude-opus', displayName: '', percentage: 85 }]; + expect(getMinClaudeQuota(models)).toBe(85); + }); + + it('filters out NaN percentages', () => { + const models = [ + { name: 'claude-opus', displayName: 'Claude Opus', percentage: NaN }, + { name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: 80 }, + ]; + expect(getMinClaudeQuota(models)).toBe(80); + }); + + it('filters out Infinity percentages', () => { + const models = [ + { name: 'claude-opus', displayName: 'Claude Opus', percentage: Infinity }, + { name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: 75 }, + ]; + expect(getMinClaudeQuota(models)).toBe(75); + }); + + it('returns null when all percentages are invalid', () => { + const models = [ + { name: 'claude-opus', displayName: 'Claude Opus', percentage: NaN }, + { name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: Infinity }, + ]; + expect(getMinClaudeQuota(models)).toBeNull(); + }); + }); + + describe('real-world scenarios', () => { + it('matches screenshot scenario - Claude 95%, Gemini 100%', () => { + const models = [ + { + name: 'claude-opus-4-5-thinking', + displayName: 'Claude Opus 4.5 (Thinking)', + percentage: 95, + }, + { name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5', percentage: 95 }, + { + name: 'claude-sonnet-4-5-thinking', + displayName: 'Claude Sonnet 4.5 (Thinking)', + percentage: 95, + }, + { name: 'gemini-2-5-flash', displayName: 'Gemini 2.5 Flash', percentage: 100 }, + { + name: 'gemini-2-5-flash-thinking', + displayName: 'Gemini 2.5 Flash (Thinking)', + percentage: 100, + }, + { name: 'gemini-2-5-flash-lite', displayName: 'Gemini 2.5 Flash Lite', percentage: 100 }, + { name: 'gemini-2-5-pro', displayName: 'Gemini 2.5 Pro', percentage: 100 }, + { name: 'gemini-3-flash', displayName: 'Gemini 3 Flash', percentage: 98 }, + { name: 'gemini-3-pro-high', displayName: 'Gemini 3 Pro (High)', percentage: 100 }, + { name: 'gemini-3-pro-low', displayName: 'Gemini 3 Pro (Low)', percentage: 100 }, + { name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B (Medium)', percentage: 95 }, + ]; + // Should return 95 (min of Claude), not 99 (avg of all) + expect(getMinClaudeQuota(models)).toBe(95); + }); + + it('handles mixed Claude percentages', () => { + const models = [ + { name: 'claude-opus-4', displayName: 'Claude Opus 4', percentage: 80 }, + { name: 'claude-sonnet-4', displayName: 'Claude Sonnet 4', percentage: 95 }, + { name: 'claude-haiku-3', displayName: 'Claude Haiku 3', percentage: 100 }, + { name: 'gemini-flash', displayName: 'Gemini Flash', percentage: 100 }, + ]; + expect(getMinClaudeQuota(models)).toBe(80); + }); + }); +}); + +describe('sortModelsByPriority', () => { + it('sorts Claude models first', () => { + const models = [ + { name: 'gemini-flash', displayName: 'Gemini Flash' }, + { name: 'claude-opus', displayName: 'Claude Opus' }, + { name: 'gpt-4', displayName: 'GPT-4' }, + ]; + const sorted = sortModelsByPriority(models); + expect(sorted[0].name).toBe('claude-opus'); + expect(sorted[1].name).toBe('gemini-flash'); + expect(sorted[2].name).toBe('gpt-4'); + }); + + it('sorts alphabetically within same priority', () => { + const models = [ + { name: 'claude-sonnet', displayName: 'Claude Sonnet' }, + { name: 'claude-opus', displayName: 'Claude Opus' }, + { name: 'claude-haiku', displayName: 'Claude Haiku' }, + ]; + const sorted = sortModelsByPriority(models); + expect(sorted[0].displayName).toBe('Claude Haiku'); + expect(sorted[1].displayName).toBe('Claude Opus'); + expect(sorted[2].displayName).toBe('Claude Sonnet'); + }); + + it('does not mutate original array', () => { + const models = [ + { name: 'gemini-flash', displayName: 'Gemini Flash' }, + { name: 'claude-opus', displayName: 'Claude Opus' }, + ]; + const original = [...models]; + sortModelsByPriority(models); + expect(models).toEqual(original); + }); +}); + +describe('getEarliestResetTime', () => { + it('returns null for empty array', () => { + expect(getEarliestResetTime([])).toBeNull(); + }); + + it('returns null when all resetTime are null', () => { + const models = [{ resetTime: null }, { resetTime: null }]; + expect(getEarliestResetTime(models)).toBeNull(); + }); + + it('returns earliest reset time', () => { + const models = [ + { resetTime: '2026-01-01T16:00:00Z' }, + { resetTime: '2026-01-01T14:00:00Z' }, + { resetTime: '2026-01-01T18:00:00Z' }, + ]; + expect(getEarliestResetTime(models)).toBe('2026-01-01T14:00:00Z'); + }); + + it('handles mixed null and valid reset times', () => { + const models = [ + { resetTime: null }, + { resetTime: '2026-01-01T14:00:00Z' }, + { resetTime: null }, + ]; + expect(getEarliestResetTime(models)).toBe('2026-01-01T14:00:00Z'); + }); +});