codeburn/app/renderer/sections/Overview.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

484 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Polled } from '../hooks/usePolled'
import type { ActReportJson, MenubarPayload, YieldJsonReport } from '../lib/types'
import { Overview, OverviewContent, localDateKey } from './Overview'
function polled(data: MenubarPayload): Polled<MenubarPayload> {
return { data, error: null, loading: false, lastSuccessAt: Date.now(), refresh: vi.fn() }
}
// Mock the typed bridge so the section fetches our payload instead of spawning
// the CLI. `normalizeCliError` (used by usePolled) is kept from the real module.
// `vi.hoisted` lets the hoisted `vi.mock` factory reference the spy safely.
const { getOverview, getActReport, getYield } = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
getActReport: vi.fn<() => Promise<ActReportJson>>(),
getYield: vi.fn<(period: string) => Promise<YieldJsonReport>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getOverview, getActReport, getYield } }
})
function makeYieldReport(): YieldJsonReport {
return {
period: { label: 'Last 30 days', start: '2026-06-12', end: '2026-07-11' },
summary: {
productive: { costUSD: 120, sessions: 3, costPercent: 80, sessionPercent: 60 },
reverted: { costUSD: 20, sessions: 1, costPercent: 13, sessionPercent: 20 },
abandoned: { costUSD: 10, sessions: 1, costPercent: 7, sessionPercent: 20 },
total: { costUSD: 150, sessions: 5 },
productiveToRevertedCostRatio: 6,
},
details: [
{ sessionId: 's1', project: 'parser-service', category: 'productive', commitCount: 4, costUSD: 80 },
{ sessionId: 's2', project: 'pairing-svc', category: 'productive', commitCount: 2, costUSD: 40 },
{ sessionId: 's3', project: 'parser-service', category: 'reverted', commitCount: 0, costUSD: 20 },
{ sessionId: 's4', project: 'scratch', category: 'abandoned', commitCount: 0, costUSD: 10 },
],
}
}
/**
* A fully-typed payload anchored to `now` so today/MTD/projected are stable.
* `history.daily` deliberately spans 30 backfill days (the real CLI emits up to
* 365 regardless of period) so tests can prove period aggregation while the
* trend chart keeps the real last 30 entries.
*/
function makePayload(now: Date): MenubarPayload {
const DAYS = 30
// 30 daily entries ending today. Base $5, a clear peak ($32) and runner-up
// ($28), and today's entry (last) at $6.20 — the Today card's source value.
const daily = Array.from({ length: DAYS }, (_, i) => {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (DAYS - 1 - i))
let cost = 5
if (i === 10) cost = 32 // peak → .c.hi
else if (i === 20) cost = 28 // runner-up → .c.hi2
else if (i === DAYS - 1) cost = 6.2 // today
return {
date: localDateKey(d),
cost,
savingsUSD: 0,
calls: 40,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [
{
name: 'claude-opus-4',
cost: cost - 1,
savingsUSD: 0,
calls: 30,
inputTokens: 40_000_000,
outputTokens: 2_000_000,
},
{
name: 'claude-haiku-4',
cost: 1,
savingsUSD: 0,
calls: 10,
inputTokens: 1_000,
outputTokens: 500,
},
],
}
})
return {
generated: now.toISOString(),
current: {
label: 'Last 30 days',
cost: 312.4,
calls: 4200,
sessions: 88,
oneShotRate: 0.74,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitPercent: 63.4,
codexCredits: 0,
topActivities: [
{ name: 'coding', cost: 92.5, savingsUSD: 7.2, turns: 120, oneShotRate: 0.8 },
{ name: 'debugging', cost: 41.25, savingsUSD: 2.1, turns: 64, oneShotRate: null },
],
topModels: [{ name: 'claude-opus-4', cost: 200, savingsUSD: 0, savingsBaselineModel: '', calls: 100 }],
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: {},
topProjects: [
{
name: 'parser-service',
cost: 8.41,
savingsUSD: 0,
sessions: 1,
avgCostPerSession: 8.41,
sessionDetails: [
{
cost: 8.41,
savingsUSD: 0,
calls: 41,
inputTokens: 0,
outputTokens: 0,
date: '2026-07-08',
models: [{ name: 'claude-opus-4', cost: 8.41, savingsUSD: 0 }],
},
],
},
],
modelEfficiency: [],
topSessions: [
{ project: 'parser-service', cost: 8.41, savingsUSD: 0, calls: 41, date: '2026-07-08' },
{ project: 'pairing-svc', cost: 6.12, savingsUSD: 0, calls: 33, date: '2026-07-07' },
],
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: [],
skills: [],
subagents: [],
mcpServers: [],
},
optimize: { findingCount: 3, savingsUSD: 23.6, topFindings: [] },
history: { daily },
}
}
describe('Overview', () => {
beforeEach(() => {
getOverview.mockReset()
getActReport.mockReset()
getYield.mockReset()
getActReport.mockResolvedValue({ totals: { realizedCostUSD: 84.2, measuredActions: 11 } })
getYield.mockResolvedValue(makeYieldReport())
})
afterEach(() => {
vi.useRealTimers()
})
it("renders real hero, stats, model, saved, session, and daily-chart data", async () => {
const now = new Date()
getOverview.mockResolvedValue(makePayload(now))
const { container } = render(<Overview period="30days" provider="all" />)
// The hero shows the SELECTED PERIOD's total (current.cost) + label, not
// just today — so a 30-day view reads $312.40 under "Last 30 days".
expect(await screen.findByText('$312.40')).toBeInTheDocument()
expect(screen.getByText('Last 30 days')).toBeInTheDocument()
expect(container.querySelector('.ov-streak')).toHaveTextContent('30-day streak')
// The unified hero keeps spend/savings, activity, and efficiency in one
// divided card. Success/cache now live only in the scorecard column.
const kpis = screen.getByLabelText('Key performance indicators')
expect(within(kpis).getAllByText('74%')).toHaveLength(1)
expect(within(kpis).getAllByText('63%')).toHaveLength(1)
expect(within(kpis).getByText('$84.20')).toBeInTheDocument()
expect(within(kpis).getByLabelText('Efficiency grade B')).toHaveTextContent('B')
expect(kpis.querySelector(':scope > .ov-efficiency')).toBeInTheDocument()
expect(kpis.querySelector('.ov-efficiency.ov-card')).not.toBeInTheDocument()
// The contribution grid contains the real active history days, and the
// right rail renders real, cost-sorted activity data including one-shot.
const heatmap = screen.getByRole('grid', { name: 'Daily activity contribution heatmap' })
expect(heatmap.querySelectorAll('[data-active="true"]')).toHaveLength(30)
expect(screen.getByText('30 active days')).toBeInTheDocument()
expect(screen.getByText('coding')).toBeInTheDocument()
expect(screen.getByText('$92.50')).toBeInTheDocument()
expect(screen.getByText('120 turns')).toBeInTheDocument()
expect(screen.getByText('80% one-shot')).toBeInTheDocument()
// Session row title = the session's project (topSessions has no title field).
expect(screen.getByText('parser-service')).toBeInTheDocument()
// The selected range produces one real bar per day and only its peak is highlighted.
const bars = container.querySelectorAll('.chart .col')
expect(bars).toHaveLength(30)
expect(bars[10].classList.contains('hi')).toBe(true)
expect(container.querySelectorAll('.chart .col.hi')).toHaveLength(1)
expect(bars[29]).toHaveAttribute('data-cost', '6.2')
expect(bars[29]).toHaveAttribute('data-calls', '40')
expect(bars[29]).toHaveAttribute('data-led', 'claude-opus-4')
fireEvent.mouseEnter(bars[29], { clientX: 100, clientY: 80 })
expect(screen.getByText('40 calls · claude-opus-4 led')).toBeInTheDocument()
const tooltip = screen.getByRole('tooltip')
expect(tooltip.parentElement).toBe(document.body)
expect(tooltip).toHaveStyle({ position: 'fixed' })
// Daily model breakdowns are summed over the selected period and sorted by
// cost. Token values use the same compact B/M/K notation as the menubar.
const modelsTable = screen.getByRole('table', { name: 'Models this period' })
expect(within(modelsTable).queryByRole('columnheader', { name: 'Relative cost' })).not.toBeInTheDocument()
const modelRows = within(modelsTable).getAllByRole('row')
expect(modelRows[1]).toHaveTextContent('claude-opus-4')
expect(modelRows[1]).toHaveTextContent('1.2B')
// Tokens now use the shared formatCompact helper (drops a trailing .0).
expect(modelRows[1]).toHaveTextContent('60M')
expect(modelRows[1]).toHaveTextContent('$171.20')
expect(modelRows[1]).toHaveTextContent('900')
expect(modelRows[2]).toHaveTextContent('claude-haiku-4')
expect(modelRows[2]).toHaveTextContent('30K')
expect(modelRows[2]).toHaveTextContent('15K')
// Weekly labels align to every seventh bar, and all three menubar-style
// daily summaries are derived from the displayed 30-day chart window.
const ticks = container.querySelectorAll('.ov-xax span')
expect(ticks).toHaveLength(5)
expect(ticks[0]).toHaveTextContent(new Date(
now.getFullYear(), now.getMonth(), now.getDate() - 29,
).toLocaleString('en-US', { month: 'short', day: 'numeric' }))
const summaries = screen.getByLabelText('Daily spend summary')
expect(within(summaries).getByText('Avg/day')).toBeInTheDocument()
expect(within(summaries).getByText('$6.71')).toBeInTheDocument()
expect(within(summaries).getByText('Peak')).toBeInTheDocument()
expect(within(summaries).getByText(/\$32\.00 · \d{1,2}\/\d{1,2}/)).toBeInTheDocument()
expect(within(summaries).getByText('Yesterday')).toBeInTheDocument()
expect(within(summaries).getByText('$5.00')).toBeInTheDocument()
expect(container.querySelector('.ov-spark')).not.toBeInTheDocument()
// Scope to the KPI card (kpis, declared above): month-to-date spend can
// equal the saved figure on some dates, so an unscoped getByText('$84.20')
// would match two cards.
expect(within(kpis).getByText('$84.20')).toBeInTheDocument()
expect(within(kpis).getByText('across 11 fixes')).toBeInTheDocument()
const statsCard = screen.getByText('Month to date').closest('.ov-stats3')
expect(statsCard).toHaveClass('ov-card')
expect(statsCard?.children).toHaveLength(2)
expect(within(statsCard as HTMLElement).getByText('Projected month')).toBeInTheDocument()
expect(screen.queryByText('Nearest limit')).not.toBeInTheDocument()
})
it('renders efficiency, cost-per-outcome, and real anomaly widgets', async () => {
const now = new Date()
const payload = makePayload(now)
const today = payload.history.daily.at(-1)
if (!today) throw new Error('fixture must contain today')
today.cost = 50 // Prior same weekdays are $5, so the real detector reports 10×.
getOverview.mockResolvedValue(payload)
render(<Overview period="30days" provider="all" />)
expect(await screen.findByLabelText('Efficiency grade B')).toHaveTextContent('B')
const outcome = screen.getByText('Cost per outcome').closest('.ov-panel')
expect(outcome).not.toBeNull()
expect(within(outcome as HTMLElement).getByText('$25.00')).toBeInTheDocument()
expect(within(outcome as HTMLElement).getByText('$40.00')).toBeInTheDocument()
const anomalies = screen.getByLabelText('Spend anomalies')
expect(anomalies).toHaveTextContent(/Today's spend is 10× your typical/)
})
it('uses an honest empty state when no realized savings exist', async () => {
const now = new Date()
getOverview.mockResolvedValue(makePayload(now))
getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 7 } })
render(<Overview period="30days" provider="all" />)
expect(await screen.findByText('across 0 fixes')).toBeInTheDocument()
await waitFor(() => expect(getActReport).toHaveBeenCalled())
expect(screen.getByText('$0.00')).toBeInTheDocument()
})
it('zero-fills a contiguous 30-day window from sparse history', async () => {
const now = new Date()
const payload = makePayload(now)
payload.history.daily = payload.history.daily.slice(-5)
getOverview.mockResolvedValue(payload)
// history.daily is sparse (active days only). The daily chart zero-fills a
// contiguous calendar window (at least 30 days) so gaps read as real
// calendar time instead of compressed bars — the date keys match localDateKey.
const { container } = render(<Overview period="week" provider="all" />)
expect(await screen.findByText('parser-service')).toBeInTheDocument()
const bars = container.querySelectorAll('.chart .col')
expect(bars).toHaveLength(30)
// The five real days keep their cost at the end; the leading 25 days are zeros.
expect([...bars].slice(-5).map(bar => bar.getAttribute('data-cost'))).toEqual(['5', '5', '5', '5', '6.2'])
expect([...bars].slice(0, 25).every(bar => bar.getAttribute('data-cost') === '0')).toBe(true)
})
it('computes month-to-date, projection, and previous-month pace', async () => {
const now = new Date(2026, 6, 15, 12, 0, 0) // Wed Jul 15 2026, local
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(now)
const payload = makePayload(now)
// Prepend three high-spend May days. The pace comparator must be the PREVIOUS
// calendar month (June) only — averaging all prior months (incl. May) would
// skew the % and mislabel it, which is the bug this guards.
const mkMay = (date: string) => ({
date,
cost: 50,
savingsUSD: 0,
calls: 40,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
})
payload.history.daily = [mkMay('2026-05-10'), mkMay('2026-05-11'), mkMay('2026-05-12'), ...payload.history.daily]
getOverview.mockResolvedValue(payload)
render(<Overview period="30days" provider="all" />)
// MTD = sum of July daily costs = 13×$5 + $28 + $6.20 = $99.20.
expect(await screen.findByText('$99.20')).toBeInTheDocument()
// Projected = MTD + median(trailing-7 = $5) × 16 days left = $179.20.
expect(screen.getByText('$179.20')).toBeInTheDocument()
expect(screen.getByText('$80.00 to go')).toBeInTheDocument()
// Pace compares July's daily avg (6.613) to the PREVIOUS calendar month's
// (June: 14×$5 + $32 = $102 / 15 = 6.8) → -3%, and the label names June.
expect(screen.getByText('-3% vs June pace')).toBeInTheDocument()
})
it('recovers a matched session model for the sub-line without a series dot', async () => {
const now = new Date()
getOverview.mockResolvedValue(makePayload(now))
const { container } = render(<Overview period="30days" provider="all" />)
// parser-service session joins topProjects sessionDetails on
// (project|date|calls|cost) → claude-opus-4 in the sub-line.
expect(await screen.findByText('Jul 8 · claude-opus-4 · 41 calls')).toBeInTheDocument()
// pairing-svc has no matching project → model omitted from sub.
expect(screen.getByText('Jul 7 · 33 calls')).toBeInTheDocument()
expect(container.querySelectorAll('.ov-sessions-widget .mdot')).toHaveLength(0)
})
it('disambiguates two same-project/same-day/same-calls sessions by cost', async () => {
const now = new Date()
const base = makePayload(now)
// Two sessions identical on (project, date, calls) but differing in cost and
// model. Without cost in the join key they collide; with it they resolve.
const payload: MenubarPayload = {
...base,
current: {
...base.current,
topProjects: [
{
name: 'svc',
cost: 12,
savingsUSD: 0,
sessions: 2,
avgCostPerSession: 6,
sessionDetails: [
{
cost: 10,
savingsUSD: 0,
calls: 20,
inputTokens: 0,
outputTokens: 0,
date: '2026-07-08',
models: [{ name: 'claude-opus-4', cost: 10, savingsUSD: 0 }],
},
{
cost: 2,
savingsUSD: 0,
calls: 20,
inputTokens: 0,
outputTokens: 0,
date: '2026-07-08',
models: [{ name: 'claude-haiku-4', cost: 2, savingsUSD: 0 }],
},
],
},
],
topSessions: [
{ project: 'svc', cost: 10, savingsUSD: 0, calls: 20, date: '2026-07-08' },
{ project: 'svc', cost: 2, savingsUSD: 0, calls: 20, date: '2026-07-08' },
],
},
}
getOverview.mockResolvedValue(payload)
const { container } = render(<Overview period="30days" provider="all" />)
expect(await screen.findByText('Jul 8 · claude-opus-4 · 20 calls')).toBeInTheDocument()
expect(screen.getByText('Jul 8 · claude-haiku-4 · 20 calls')).toBeInTheDocument()
expect(container.querySelectorAll('.ov-sessions-widget .mdot')).toHaveLength(0)
})
it('shows the first-run locate-CLI state when the binary is missing', async () => {
getOverview.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' })
render(<Overview period="30days" provider="all" />)
expect(await screen.findByText(/Locate the codeburn CLI/i)).toBeInTheDocument()
})
it('sources Models this period from current.topModels when a provider filter is active', async () => {
const now = new Date()
const payload = makePayload(now)
// Provider-filtered CLI output: history.daily loses its per-model breakdown,
// but current is already provider-scoped.
payload.history.daily = payload.history.daily.map(day => ({ ...day, topModels: [] }))
payload.current.topModels = [
{ name: 'gpt-5.5-codex', cost: 120, savingsUSD: 0, savingsBaselineModel: '', calls: 240 },
{ name: 'claude-opus-4', cost: 60, savingsUSD: 0, savingsBaselineModel: '', calls: 90 },
]
render(<OverviewContent period="30days" provider="codex" overview={polled(payload)} />)
const modelsTable = await screen.findByRole('table', { name: 'Models this period' })
const rows = within(modelsTable).getAllByRole('row')
// Sourced from current.topModels (cost-sorted), not the now-empty daily aggregation.
expect(rows[1]).toHaveTextContent('gpt-5.5-codex')
expect(rows[1]).toHaveTextContent('$120.00')
expect(rows[1]).toHaveTextContent('240')
expect(rows[2]).toHaveTextContent('claude-opus-4')
// current.topModels carries no per-model tokens → both token cells show a dash.
expect(within(rows[1] as HTMLElement).getAllByText('—')).toHaveLength(2)
})
it('suppresses the week-over-week anomaly and MTD card for a custom range', async () => {
const now = new Date()
const overview = polled(makePayload(now))
const { rerender } = render(<OverviewContent period="30days" provider="all" overview={overview} />)
// Baseline (no range): the MTD card and a week-over-week signal are present.
expect(await screen.findByText('Month to date')).toBeInTheDocument()
expect(screen.getAllByText(/than last week/).length).toBeGreaterThan(0)
const from = localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 2))
const to = localDateKey(now)
rerender(<OverviewContent period="30days" provider="all" range={{ from, to }} overview={overview} />)
expect(screen.queryByText('Month to date')).not.toBeInTheDocument()
expect(screen.queryByText('Projected month')).not.toBeInTheDocument()
expect(screen.queryByText(/than last week/)).toBeNull()
expect(screen.getByText(/is the biggest driver in this range/)).toBeInTheDocument()
})
it('renders local-model savings in the hero only when present', async () => {
const now = new Date()
const payload = makePayload(now)
payload.current.localModelSavings = { totalUSD: 42.5, calls: 10, byModel: [], byProvider: [] }
render(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)
expect(await screen.findByText('Saved by applied fixes')).toBeInTheDocument()
expect(screen.getByText('Saved via local models')).toBeInTheDocument()
expect(screen.getByText('$42.50')).toBeInTheDocument()
expect(screen.queryByText('Saved to date')).not.toBeInTheDocument()
})
it('shows a stale banner when last-good data is present but the latest poll failed', async () => {
const now = new Date()
const overview: Polled<MenubarPayload> = {
data: makePayload(now),
error: { kind: 'nonzero', message: 'codeburn exited 1' },
loading: false,
lastSuccessAt: Date.now(),
refresh: vi.fn(),
}
render(<OverviewContent period="30days" provider="all" overview={overview} />)
expect(await screen.findByRole('status')).toHaveTextContent('Refresh failed, showing last good data · codeburn exited 1')
})
})