// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AuditRow, ModelReportRow } from '../lib/types' import { Models } from './Models' const { getModels, getAudit } = vi.hoisted(() => ({ getModels: vi.fn<(period: string, provider: string, byTask: boolean) => Promise>(), getAudit: vi.fn<(period: string, provider: string) => Promise>(), })) vi.mock('../lib/ipc', async orig => { const actual = await orig() return { ...actual, codeburn: { getModels, getAudit } } }) const rows: ModelReportRow[] = [ { provider: 'anthropic', providerDisplayName: 'Anthropic', model: 'claude-opus-4.8', modelDisplayName: 'Claude Opus 4.8', category: null, topCategory: 'coding', topCategoryShare: 0.71, inputTokens: 152_600_000, outputTokens: 9_640_000, cacheWriteTokens: 16_000_000, cacheReadTokens: 119_400_000, totalTokens: 297_640_000, calls: 4812, costUSD: 331.2, savingsUSD: 86.4, savingsBaselineModel: 'Claude Opus 4.8', credits: null, }, { provider: 'codex', providerDisplayName: 'Codex', model: 'gpt-5.5-codex', modelDisplayName: 'GPT-5.5 Codex', category: null, topCategory: 'debugging', topCategoryShare: 0.42, inputTokens: 86_900_000, outputTokens: 7_520_000, cacheWriteTokens: 3_200_000, cacheReadTokens: 45_100_000, totalTokens: 142_720_000, calls: 2704, costUSD: 137.9, savingsUSD: 35.1, savingsBaselineModel: 'GPT-5.5 Codex', credits: 173, }, { provider: 'local', providerDisplayName: 'Local', model: 'llama-local', modelDisplayName: 'Llama Local', category: null, inputTokens: 750_000, outputTokens: 400_000, cacheWriteTokens: 0, cacheReadTokens: 0, totalTokens: 1_150_000, calls: 82, costUSD: 0, savingsUSD: 12.34, savingsBaselineModel: 'Claude Opus 4.8', credits: null, }, { provider: 'custom', providerDisplayName: 'Custom', model: 'my-proxy-model', modelDisplayName: 'my-proxy-model', category: null, inputTokens: 4_800_000, outputTokens: 400_000, cacheWriteTokens: 0, cacheReadTokens: 0, totalTokens: 5_200_000, calls: 176, costUSD: 0, savingsUSD: 0, savingsBaselineModel: '', credits: null, }, ] const byTaskRows: ModelReportRow[] = [ { ...rows[0], category: 'coding', calls: 3400, inputTokens: 100_000_000, outputTokens: 6_100_000, cacheReadTokens: 88_000_000, totalTokens: 210_100_000, costUSD: 244.12, savingsUSD: 61.22, }, { ...rows[0], category: 'delegation', calls: 120, inputTokens: 8_000_000, outputTokens: 500_000, cacheReadTokens: 6_000_000, totalTokens: 14_500_000, costUSD: 20.88, savingsUSD: 5.18, }, ] const auditRows: AuditRow[] = [ { provider: 'anthropic', providerDisplayName: 'Anthropic', model: 'claude-opus-4.8', modelDisplayName: 'Claude Opus 4.8', calls: 1200, raw: { inputTokens: 50_000_000, outputTokens: 3_100_000, reasoningTokens: 900_000, cacheCreationInputTokens: 8_000_000, cacheReadInputTokens: 40_000_000, cachedInputTokens: 0, webSearchRequests: 0 }, displayed: { inputTokens: 50_000_000, outputTokens: 4_000_000, cacheWriteTokens: 8_000_000, cacheReadTokens: 40_000_000 }, rates: { inputCostPerToken: 0.000003, outputCostPerToken: 0.000015, cacheWriteCostPerToken: 0.00000375, cacheReadCostPerToken: 0.0000003, webSearchCostPerRequest: 0.01, fastMultiplier: 1 }, cost: { input: 150, output: 60, cacheWrite: 30, cacheRead: 12, webSearch: 0, recomputedTotalUSD: 252 }, attributedCostUSD: 252, }, { provider: 'custom', providerDisplayName: 'Custom', model: 'my-proxy-model', modelDisplayName: 'my-proxy-model', calls: 90, raw: { inputTokens: 4_800_000, outputTokens: 400_000, reasoningTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, webSearchRequests: 0 }, displayed: { inputTokens: 4_800_000, outputTokens: 400_000, cacheWriteTokens: 0, cacheReadTokens: 0 }, rates: null, cost: { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, webSearch: 0, recomputedTotalUSD: 0 }, attributedCostUSD: 0, }, ] describe('Models', () => { beforeEach(() => { getModels.mockReset() getAudit.mockReset() }) it('renders priced model rows with series dots, costs, and savings', async () => { getModels.mockResolvedValue(rows) const { container } = render() expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument() expect(screen.getByText('4,812')).toBeInTheDocument() expect(screen.getByText('152.6M')).toBeInTheDocument() expect(screen.getByText('9.6M')).toBeInTheDocument() expect(screen.getByText('119.4M')).toBeInTheDocument() expect(screen.getByText('$331.20')).toBeInTheDocument() expect(screen.getByText('$86.40')).toHaveClass('pos') expect(screen.getByText('GPT-5.5 Codex')).toBeInTheDocument() expect(screen.getByText('$137.90')).toBeInTheDocument() expect(screen.getByText('$35.10')).toHaveClass('pos') const dots = [...container.querySelectorAll('.mdot')] expect(dots[0]).toHaveAttribute('style', expect.stringContaining('var(--s-opus)')) expect(dots[1]).toHaveAttribute('style', expect.stringContaining('var(--s-gpt)')) }) it('names the provider on each model row so duplicate model names stay distinguishable', async () => { const dupRows: ModelReportRow[] = [ { ...rows[0], provider: 'minimax', providerDisplayName: 'MiniMax', model: 'minimax-m3', modelDisplayName: 'MiniMax M3' }, { ...rows[1], provider: 'openrouter', providerDisplayName: 'OpenRouter', model: 'minimax-m3', modelDisplayName: 'MiniMax M3' }, ] getModels.mockResolvedValue(dupRows) render() // Same display name, two rows — each tagged with its own provider. expect(await screen.findAllByText('MiniMax M3')).toHaveLength(2) expect(screen.getByText('MiniMax')).toBeInTheDocument() expect(screen.getByText('OpenRouter')).toBeInTheDocument() }) it('names the provider on each by-task model group', async () => { getModels.mockResolvedValueOnce([rows[0]]).mockResolvedValueOnce(byTaskRows) render() expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument() fireEvent.click(screen.getByRole('tab', { name: 'By task' })) expect(await screen.findByText('coding')).toBeInTheDocument() expect(screen.getByText('Anthropic')).toBeInTheDocument() }) it('renders codex rows with credits and real cost as priced', async () => { getModels.mockResolvedValue([rows[1]]) render() expect(await screen.findByText('GPT-5.5 Codex')).not.toHaveClass('dim') expect(screen.getByText('$137.90')).not.toHaveClass('dim') expect(screen.getByText('$35.10')).toHaveClass('pos') expect(screen.queryByText('add alias ›')).not.toBeInTheDocument() }) it('renders local saved-only rows as priced with real savings', async () => { getModels.mockResolvedValue([rows[2]]) render() expect(await screen.findByText('Llama Local')).not.toHaveClass('dim') expect(screen.getByText('750K')).toBeInTheDocument() expect(screen.getByText('400K')).toBeInTheDocument() expect(screen.getByText('$0.00')).not.toHaveClass('dim') expect(screen.getByText('$12.34')).toHaveClass('pos') expect(screen.queryByText('add alias ›')).not.toBeInTheDocument() }) it('renders unpriced proxy rows as dim with alias affordance and dashes', async () => { getModels.mockResolvedValue([rows[3]]) render() expect(await screen.findByText('my-proxy-model')).toHaveClass('dim') expect(screen.getByText('add alias ›')).toHaveClass('alias') expect(screen.getAllByText('—')).toHaveLength(5) expect(screen.queryByText('$0.00')).not.toBeInTheDocument() }) it('refetches with byTask=true and renders the task category', async () => { getModels.mockResolvedValueOnce(rows).mockResolvedValueOnce(byTaskRows) render() expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument() expect(getModels).toHaveBeenCalledWith('week', 'anthropic', false) fireEvent.click(screen.getByRole('tab', { name: 'By task' })) await waitFor(() => expect(getModels).toHaveBeenCalledWith('week', 'anthropic', true)) expect(await screen.findByText('coding')).toBeInTheDocument() expect(screen.getByText('delegation')).toBeInTheDocument() expect(screen.getByText('3,520')).toBeInTheDocument() expect(screen.getByText('$265.00')).toBeInTheDocument() expect(screen.getByText('$66.40')).toBeInTheDocument() expect(screen.getByText('$244.12')).toBeInTheDocument() expect(screen.getAllByText('Claude Opus 4.8')).toHaveLength(1) expect(document.querySelectorAll('.model-task-group')).toHaveLength(1) expect(document.querySelectorAll('.model-task-row')).toHaveLength(2) }) it('renders the audit lens with raw vs normalized token columns and an estimated flag', async () => { getModels.mockResolvedValue(rows) getAudit.mockResolvedValue(auditRows) render() expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument() fireEvent.click(screen.getByRole('tab', { name: 'Audit' })) expect(await screen.findByText('my-proxy-model')).toBeInTheDocument() expect(getAudit).toHaveBeenCalledWith('30days', 'all') // Raw output (3.1M) and normalized output (4M = output + reasoning) both show. expect(screen.getByText('3.1M')).toBeInTheDocument() expect(screen.getByText('900K')).toBeInTheDocument() expect(screen.getByText('4M')).toBeInTheDocument() expect(screen.getByText('$252.00')).toBeInTheDocument() // Only the unpriced row (rates: null) is flagged estimated. expect(screen.getAllByText('est')).toHaveLength(1) }) it('shows the audit empty state when there is nothing to audit', async () => { getModels.mockResolvedValue(rows) getAudit.mockResolvedValue([]) render() expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument() fireEvent.click(screen.getByRole('tab', { name: 'Audit' })) expect(await screen.findByText('No model usage to audit in this range yet.')).toBeInTheDocument() }) })