codeburn/app/renderer/sections/Spend.test.tsx
iamtoruk cbddd3a9d7 fix(app): accuracy batch A — provider scoping, honest chart axes, range threading, stale-error banner
- Provider filter: Models-this-period sources current.topModels (history
  days carry empty topModels per provider); StackedBars falls back to a
  cost-proportional single segment like the Swift menubar
- Chart windows align to the CLI (week -7, 30days -30, all = 6 months)
  and zero-fill a contiguous calendar window; history.daily is sparse and
  both sides key local YYYY-MM-DD, so lookups are safe (verified on real
  CLI output)
- Custom range threads into Overview panels (chart, models table) and
  suppresses MTD/projected and vs-last-week comparisons; Compare states
  that custom dates fall back to the period
- usePolled clears errors per attempt and sections show a StaleBanner
  instead of silently rendering last-good data after a failed refresh
- Cache-hit denominator matches the CLI; hero savings relabeled 'Saved by
  applied fixes' + new 'Saved via local models' line; formatCompact
  everywhere; Optimize 'Fixes' count matches the rendered list

App suite 163/163 (was 147), root 1613/1613.
2026-07-16 01:14:41 -07:00

331 lines
13 KiB
TypeScript

// @vitest-environment jsdom
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MenubarPayload, SpendFlow } from '../lib/types'
import { Spend } from './Spend'
const { getOverview, getSpendFlow } = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string) => Promise<SpendFlow>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getOverview, getSpendFlow } }
})
function daily(date: string, cost: number, models: Array<{ name: string; cost: number }>) {
return {
date,
cost,
savingsUSD: 0,
calls: 10,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: models.map(m => ({
name: m.name,
cost: m.cost,
savingsUSD: 0,
calls: 5,
inputTokens: 0,
outputTokens: 0,
})),
}
}
function makePayload(now: Date): MenubarPayload {
return {
generated: now.toISOString(),
current: {
label: 'Last 30 days',
cost: 612.48,
calls: 1220,
sessions: 88,
oneShotRate: null,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitPercent: 0,
codexCredits: 0,
topActivities: [{ name: 'coding', cost: 42, savingsUSD: 0, turns: 12, oneShotRate: null }],
topModels: [],
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: {},
topProjects: [
{
name: 'codeburn',
cost: 246.1,
savingsUSD: 0,
sessions: 124,
avgCostPerSession: 1.98,
sessionDetails: [],
},
{
name: 'agentseal-dash',
cost: 141.3,
savingsUSD: 0,
sessions: 74,
avgCostPerSession: 1.91,
sessionDetails: [],
},
],
modelEfficiency: [],
topSessions: [],
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: [{ name: 'Read', calls: 30 }],
skills: [{ name: 'imagegen', turns: 3, cost: 1.25 }],
subagents: [{ name: 'reviewer', calls: 2, cost: 2.5 }],
mcpServers: [{ name: 'filesystem', calls: 9 }],
},
optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
history: {
daily: [
daily('2026-06-30', 11, [{ name: 'claude-opus-4', cost: 11 }]),
daily('2026-07-01', 12, [{ name: 'gpt-5.5-codex', cost: 12 }]),
daily('2026-07-04', 13, [{ name: 'claude-opus-4', cost: 9 }, { name: 'claude-sonnet-5', cost: 4 }]),
daily('2026-07-06', 8, [{ name: 'claude-haiku-4', cost: 8 }]),
daily('2026-07-10', 15, [{ name: 'gpt-5.5-codex', cost: 15 }]),
],
},
}
}
function makeFlow(): SpendFlow {
return {
period: { label: 'Last 7 days', start: '2026-07-04', end: '2026-07-10' },
models: [
{ id: 'claude-opus-4-20260701', label: 'claude-opus-4-20260701', cost: 22 },
{ id: 'gpt-5.5-codex', label: 'gpt-5.5-codex', cost: 18 },
],
projects: [
{ id: '/Users/me/src/mobile-app', label: '/Users/me/src/mobile-app', cost: 30 },
{ id: '__other__', label: '__other__', cost: 10 },
],
links: [
{ model: 'claude-opus-4-20260701', project: '/Users/me/src/mobile-app', cost: 18 },
{ model: 'claude-opus-4-20260701', project: '__other__', cost: 4 },
{ model: 'gpt-5.5-codex', project: '/Users/me/src/mobile-app', cost: 12 },
{ model: 'gpt-5.5-codex', project: '__other__', cost: 6 },
],
}
}
function emptyFlow(): SpendFlow {
return {
period: { label: 'Last 7 days', start: '2026-07-04', end: '2026-07-10' },
models: [],
projects: [],
links: [],
}
}
describe('Spend', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date(2026, 6, 10, 12, 0, 0))
getOverview.mockReset()
getSpendFlow.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('zero-fills a contiguous 15-day calendar window with a real date axis, projects, and Sankey ribbons', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
const { container } = render(<Spend period="week" provider="all" />)
expect(await screen.findByText('codeburn')).toBeInTheDocument()
expect(screen.getByText('$246.10')).toBeInTheDocument()
expect(screen.getByText('agentseal-dash')).toBeInTheDocument()
expect(screen.getByText('top 2')).toBeInTheDocument()
// Sparse history is zero-filled into 15 contiguous days ending today (Jul 10),
// so the axis reflects real calendar spacing rather than compressing gaps.
const barColumns = container.querySelectorAll('.sbars .c')
expect(barColumns).toHaveLength(15)
expect([...barColumns].map(col => col.getAttribute('data-date'))).toEqual([
'2026-06-26', '2026-06-27', '2026-06-28', '2026-06-29', '2026-06-30',
'2026-07-01', '2026-07-02', '2026-07-03', '2026-07-04', '2026-07-05',
'2026-07-06', '2026-07-07', '2026-07-08', '2026-07-09', '2026-07-10',
])
const ticks = container.querySelectorAll('.sbars-wrap > .ov-xax span')
expect([...ticks].map(tick => tick.textContent)).toEqual(['Jun 26', 'Jun 30', 'Jul 4', 'Jul 8', 'Jul 10'])
expect(container.querySelectorAll('[data-testid="sankey-ribbon"]')).toHaveLength(makeFlow().links.length)
})
it('renders the chart, projects, Sankey, and all non-empty breakdowns on one page', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
render(<Spend period="week" provider="all" />)
expect(await screen.findByLabelText('Daily spend by model')).toBeInTheDocument()
expect(screen.getByText('By project')).toBeInTheDocument()
expect(screen.getByText('Cost flow · model → project')).toBeInTheDocument()
expect(screen.getByText('Activity')).toBeInTheDocument()
expect(screen.getByText('coding')).toBeInTheDocument()
expect(screen.getByText('imagegen')).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
expect(screen.getByText('Read')).toBeInTheDocument()
expect(screen.getByText('MCP')).toBeInTheDocument()
expect(screen.getByText('filesystem')).toBeInTheDocument()
expect(screen.getByText('Subagents')).toBeInTheDocument()
expect(screen.getByText('reviewer')).toBeInTheDocument()
})
it.each(['Activity', 'Tools', 'MCP', 'Subagents'])('hides an empty %s breakdown', async title => {
const payload = makePayload(new Date())
if (title === 'Activity') {
payload.current.topActivities = []
payload.current.skills = []
} else if (title === 'Tools') {
payload.current.tools = []
} else if (title === 'MCP') {
payload.current.mcpServers = []
} else {
payload.current.subagents = []
}
getOverview.mockResolvedValue(payload)
getSpendFlow.mockResolvedValue(makeFlow())
render(<Spend period="week" provider="all" />)
expect(await screen.findByText('codeburn')).toBeInTheDocument()
expect(screen.queryByText(title)).not.toBeInTheDocument()
})
it('shows one compact empty state when every breakdown is empty', async () => {
const payload = makePayload(new Date())
payload.current.topActivities = []
payload.current.skills = []
payload.current.tools = []
payload.current.mcpServers = []
payload.current.subagents = []
getOverview.mockResolvedValue(payload)
getSpendFlow.mockResolvedValue(makeFlow())
render(<Spend period="week" provider="all" />)
expect(await screen.findByText('No activity, tool, MCP, or subagent data in this range yet.')).toBeInTheDocument()
expect(screen.queryByText('Activity')).not.toBeInTheDocument()
expect(screen.queryByText('Tools')).not.toBeInTheDocument()
expect(screen.queryByText('MCP')).not.toBeInTheDocument()
expect(screen.queryByText('Subagents')).not.toBeInTheDocument()
})
it('does not render the removed lens tabs', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
render(<Spend period="week" provider="all" />)
expect(await screen.findByText('codeburn')).toBeInTheDocument()
for (const name of ['Projects', 'Activity', 'Tools', 'MCP', 'Subagents']) {
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
}
})
it('groups the top panels and breakdown panels in their page grids', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
const { container } = render(<Spend period="week" provider="all" />)
expect(await screen.findByText('codeburn')).toBeInTheDocument()
expect(container.querySelector('.spend-top-row')?.children).toHaveLength(2)
expect(container.querySelector('.spend-breakdowns')?.children).toHaveLength(4)
})
it('renders empty chart and flow states when no daily spend exists', async () => {
const payload = makePayload(new Date())
payload.history.daily = []
getOverview.mockResolvedValue(payload)
getSpendFlow.mockResolvedValue(emptyFlow())
const { container } = render(<Spend period="week" provider="all" />)
expect(await screen.findByText('No model spend in this range yet.')).toBeInTheDocument()
expect(container.querySelector('.sbars')).not.toBeInTheDocument()
expect(await screen.findByText('No model-project flow in this range yet.')).toBeInTheDocument()
})
it('renders the flow error path without hiding the rest of spend', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockRejectedValue({ kind: 'nonzero', message: 'flow command failed' })
render(<Spend period="week" provider="all" />)
expect(await screen.findByText('codeburn')).toBeInTheDocument()
expect(await screen.findByText('flow command failed')).toBeInTheDocument()
})
it('renders the not-found panel when codeburn is not on PATH', async () => {
getOverview.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' })
getSpendFlow.mockResolvedValue(emptyFlow())
render(<Spend period="week" provider="all" />)
expect(await screen.findByText('Locate the codeburn CLI')).toBeInTheDocument()
expect(screen.getByText(/isn't on your PATH yet/)).toBeInTheDocument()
})
it('maps stacked segments to the expected model series classes', async () => {
const payload = makePayload(new Date())
payload.history.daily = [
daily('2026-07-10', 25, [
{ name: 'claude-opus-4', cost: 5 },
{ name: 'claude-sonnet-5', cost: 5 },
{ name: 'claude-haiku-4', cost: 5 },
{ name: 'gpt-5.5-codex', cost: 5 },
{ name: 'mystery-model', cost: 5 },
]),
]
getOverview.mockResolvedValue(payload)
getSpendFlow.mockResolvedValue(makeFlow())
const { container } = render(<Spend period="week" provider="all" />)
expect(await screen.findByLabelText('Daily spend by model')).toBeInTheDocument()
expect(container.querySelector('.sbars .s-opus')).toBeInTheDocument()
expect(container.querySelector('.sbars .s-son')).toBeInTheDocument()
expect(container.querySelector('.sbars .s-hai')).toBeInTheDocument()
expect(container.querySelector('.sbars .s-gpt')).toBeInTheDocument()
expect(container.querySelector('.sbars .s-other')).toBeInTheDocument()
expect(screen.getByText('Opus')).toBeInTheDocument()
expect(screen.getByText('Sonnet')).toBeInTheDocument()
expect(screen.getByText('Haiku')).toBeInTheDocument()
expect(screen.getByText('GPT / Codex')).toBeInTheDocument()
expect(screen.getByText('Other')).toBeInTheDocument()
expect(screen.queryByText('Opus 4.8')).not.toBeInTheDocument()
expect(screen.queryByText('Sonnet 5')).not.toBeInTheDocument()
expect(screen.queryByText('Haiku 4.5')).not.toBeInTheDocument()
expect(screen.queryByText('GPT-5.5 Codex')).not.toBeInTheDocument()
})
it('renders Sankey ribbons with model gradients, neutral other nodes, and shortened labels', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
const { container } = render(<Spend period="week" provider="all" />)
expect(await screen.findByText(/opus-4-20260701/)).toBeInTheDocument()
expect(screen.getByText(/gpt-5.5-codex/)).toBeInTheDocument()
expect(screen.queryByText(/Opus 4.8/)).not.toBeInTheDocument()
expect(screen.queryByText(/GPT-5.5 Codex/)).not.toBeInTheDocument()
expect(screen.getByText(/src\/mobile-app/)).toBeInTheDocument()
expect(screen.queryByText(/Users\/me\/src\/mobile-app/)).not.toBeInTheDocument()
const opusRibbon = container.querySelector('[data-testid="sankey-ribbon"][data-model="claude-opus-4-20260701"]')
expect(opusRibbon?.getAttribute('stroke')).toBe('url(#sankey-claude-opus-4-20260701)')
const opusStop = container.querySelector('linearGradient[id="sankey-claude-opus-4-20260701"] stop')
expect(opusStop?.getAttribute('stop-color')).toBe('var(--s-opus)')
const otherNode = container.querySelector('[data-testid="sankey-node"][data-node-id="__other__"]')
expect(otherNode?.getAttribute('fill')).toBe('var(--s-other)')
})
})