mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-31 20:05:15 +00:00
Two Phase-1 desktop additions over the renderer's existing card grammar. Overview workflow card: a panel below the daily chart with two stat tiles (correction rate with count, median time to first edit) plus a top-rework line and one coaching caption. The caption is derived locally with the CLI's buildCoachingNotes thresholds (corrections, then file churn, then time to first edit; first that fires). The card hides entirely when no real signal is present, never renders zeros, and shows a "N% priced" chip only when pricing coverage is a number below 1. Payload types gain optional workflow, topReworkedFiles, and pricingCoverage fields so older payloads render as before. Sessions view: the captured session title becomes the row headline with the id demoted to the mono secondary line; untitled rows keep the project as the headline (unchanged). Title is also searchable and added as an optional SessionRow field so older CLIs render unchanged. Co-authored-by: reviewer <review@local>
812 lines
35 KiB
TypeScript
812 lines
35 KiB
TypeScript
// @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 { setActiveCurrency } from '../lib/format'
|
||
import type { ActReportJson, DailyHistoryEntry, MenubarPayload, YieldJsonReport } from '../lib/types'
|
||
import { Overview, OverviewContent, deriveSignals, localDateKey } from './Overview'
|
||
|
||
function polled(data: MenubarPayload): Polled<MenubarPayload> {
|
||
return { data, error: null, loading: false, switching: 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, provider: 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 },
|
||
}
|
||
}
|
||
|
||
function mkDay(date: string, cost: number): DailyHistoryEntry {
|
||
return { date, cost, savingsUSD: 0, calls: 10, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, topModels: [] }
|
||
}
|
||
|
||
/** A payload with an explicit history + current overrides for deriveSignals tests. */
|
||
function signalsPayload(now: Date, over: {
|
||
current?: Partial<MenubarPayload['current']>
|
||
daily?: DailyHistoryEntry[]
|
||
optimize?: MenubarPayload['optimize']
|
||
}): MenubarPayload {
|
||
const base = makePayload(now)
|
||
return {
|
||
...base,
|
||
current: { ...base.current, ...over.current },
|
||
optimize: over.optimize ?? { findingCount: 0, savingsUSD: 0, topFindings: [] },
|
||
history: { daily: over.daily ?? [] },
|
||
}
|
||
}
|
||
|
||
/** N consecutive days ending today; `cost(i)` sets each day's spend (i = oldest→0). */
|
||
function consecutiveDays(now: Date, count: number, cost: (index: number) => number): DailyHistoryEntry[] {
|
||
return Array.from({ length: count }, (_, i) => mkDay(localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - (count - 1 - i))), cost(i)))
|
||
}
|
||
|
||
describe('Overview', () => {
|
||
beforeEach(() => {
|
||
setActiveCurrency({ code: 'USD', symbol: '$', rate: 1 })
|
||
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 the weekday-spike risk signal', 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()
|
||
// The weekday-spike anomaly is absorbed into the Signals card as a risk.
|
||
const signals = screen.getByLabelText('Coaching signals')
|
||
const risks = within(signals).getByText('Risks').closest('.ov-signal-group') as HTMLElement
|
||
expect(within(risks).getByText(/Today's spend is 10× your typical/)).toBeInTheDocument()
|
||
})
|
||
|
||
it('hides the applied-fixes line when there are no realized savings', async () => {
|
||
const now = new Date()
|
||
getOverview.mockResolvedValue(makePayload(now))
|
||
getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 7 } })
|
||
|
||
render(<Overview period="30days" provider="all" />)
|
||
|
||
await waitFor(() => expect(getActReport).toHaveBeenCalled())
|
||
expect(screen.queryByText('Saved by applied fixes')).not.toBeInTheDocument()
|
||
expect(screen.queryByText(/across \d+ fix/)).not.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('renders days before recorded history as no data, not a $0.00 bar', async () => {
|
||
const now = new Date()
|
||
const payload = makePayload(now)
|
||
payload.history.daily = payload.history.daily.slice(-5)
|
||
getOverview.mockResolvedValue(payload)
|
||
|
||
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 25 leading days predate the first recorded day: no data, not zero spend.
|
||
expect([...bars].slice(0, 25).every(bar => bar.classList.contains('nodata'))).toBe(true)
|
||
expect(bars[0].getAttribute('aria-label')).toContain('no data recorded')
|
||
// The five recorded days stay real (idle or spend), never marked no data.
|
||
expect([...bars].slice(-5).some(bar => bar.classList.contains('nodata'))).toBe(false)
|
||
|
||
fireEvent.mouseEnter(bars[0], { clientX: 100, clientY: 80 })
|
||
expect(screen.getByText('No data recorded')).toBeInTheDocument()
|
||
})
|
||
|
||
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 signal 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, the coach pacing line, and the
|
||
// week-over-week Signals entry are all present.
|
||
expect(await screen.findByText('Month to date')).toBeInTheDocument()
|
||
expect(screen.getAllByText(/than last week/).length).toBeGreaterThan(0)
|
||
expect(screen.getByText(/vs last 7 days/)).toBeInTheDocument()
|
||
|
||
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()
|
||
// The week-over-week Signals entry is suppressed under a custom range too.
|
||
expect(screen.queryByText(/vs last 7 days|vs prior 7 days/)).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,
|
||
switching: 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')
|
||
})
|
||
|
||
it('groups current-driven signals into wins and improvements', () => {
|
||
const now = new Date()
|
||
const wins = deriveSignals(signalsPayload(now, {
|
||
current: {
|
||
cacheHitPercent: 85,
|
||
oneShotRate: 0.82,
|
||
localModelSavings: { totalUSD: 15, calls: 4, byModel: [], byProvider: [] },
|
||
},
|
||
optimize: {
|
||
findingCount: 2,
|
||
savingsUSD: 30,
|
||
topFindings: [
|
||
{ title: 'Trim CLAUDE.md preamble', impact: 'high', savingsUSD: 12 },
|
||
{ title: 'Route trivial edits to Haiku', impact: 'medium', savingsUSD: 8 },
|
||
],
|
||
},
|
||
}), now, false)
|
||
expect(wins.wins.map(s => s.text)).toEqual([
|
||
'Cache hit at 85%, most prompts reuse cache',
|
||
'82% one-shot, edits land first try',
|
||
'$15.00 saved via local models',
|
||
])
|
||
expect(wins.improvements).toEqual([
|
||
{ text: 'Trim CLAUDE.md preamble', trailing: '$12.00' },
|
||
{ text: 'Route trivial edits to Haiku', trailing: '$8.00' },
|
||
])
|
||
expect(wins.risks).toEqual([])
|
||
})
|
||
|
||
it('flags low cache-hit, low one-shot, and heavy retry tax as improvements', () => {
|
||
const now = new Date()
|
||
const { improvements } = deriveSignals(signalsPayload(now, {
|
||
current: {
|
||
cacheHitPercent: 40,
|
||
oneShotRate: 0.4,
|
||
cost: 100,
|
||
retryTax: { totalUSD: 30, retries: 5, editTurns: 10, byModel: [] },
|
||
},
|
||
}), now, false)
|
||
expect(improvements.map(s => s.text)).toEqual([
|
||
'Cache hit only 40%, paying for cold prompts',
|
||
'40% one-shot, lots of iteration',
|
||
'Retry tax is 30% of spend',
|
||
])
|
||
})
|
||
|
||
it('does not treat a zero cache-hit (no data) as a cold-prompt improvement', () => {
|
||
const now = new Date()
|
||
const { improvements } = deriveSignals(signalsPayload(now, {
|
||
current: { cacheHitPercent: 0, oneShotRate: 0.6 },
|
||
}), now, false)
|
||
expect(improvements).toEqual([])
|
||
})
|
||
|
||
it('derives streak and a week-over-week drop as wins from history', () => {
|
||
const now = new Date()
|
||
// 14 consecutive active days; prior 7 at $20, recent 7 at $5 → spend down 75%.
|
||
const daily = consecutiveDays(now, 14, i => (i < 7 ? 20 : 5))
|
||
const { wins } = deriveSignals(signalsPayload(now, {
|
||
current: { cacheHitPercent: 60, oneShotRate: 0.6 },
|
||
daily,
|
||
}), now, false)
|
||
expect(wins.map(s => s.text)).toEqual([
|
||
'Spend down 75% vs last 7 days',
|
||
'14-day usage streak',
|
||
])
|
||
})
|
||
|
||
it('reports weekday spike, week-over-week rise, and month overrun as risks', () => {
|
||
const now = new Date(2026, 6, 15) // Jul 15 2026
|
||
// June total = $5 (prior-month baseline); July: prior 7 low, recent 7 high.
|
||
const daily = [mkDay('2026-06-11', 5), ...consecutiveDays(now, 14, i => (i < 7 ? 2 : 20))]
|
||
const { risks } = deriveSignals(signalsPayload(now, {
|
||
current: { cacheHitPercent: 60, oneShotRate: 0.6 },
|
||
daily,
|
||
}), now, false)
|
||
expect(risks).toHaveLength(3)
|
||
expect(risks[0].text).toMatch(/Today's spend is 10× your typical/)
|
||
expect(risks[1].text).toBe('Spend up 900% vs prior 7 days')
|
||
expect(risks[2].text).toMatch(/^On pace for .* this month, \+\d+% vs last$/)
|
||
})
|
||
|
||
it('suppresses week-over-week and projection risks under a custom range, keeping the weekday spike', () => {
|
||
const now = new Date(2026, 6, 15)
|
||
const daily = [mkDay('2026-06-11', 5), ...consecutiveDays(now, 14, i => (i < 7 ? 2 : 20))]
|
||
const payload = signalsPayload(now, { current: { cacheHitPercent: 60, oneShotRate: 0.6 }, daily })
|
||
const { risks } = deriveSignals(payload, now, true)
|
||
expect(risks).toHaveLength(1)
|
||
expect(risks[0].text).toMatch(/Today's spend is 10× your typical/)
|
||
})
|
||
|
||
it('caps each group at three signals', () => {
|
||
const now = new Date()
|
||
// Five wins would qualify (cache, one-shot, week-down, streak, local); cap keeps 3.
|
||
const daily = consecutiveDays(now, 14, i => (i < 7 ? 20 : 5))
|
||
const { wins } = deriveSignals(signalsPayload(now, {
|
||
current: {
|
||
cacheHitPercent: 85,
|
||
oneShotRate: 0.82,
|
||
localModelSavings: { totalUSD: 15, calls: 4, byModel: [], byProvider: [] },
|
||
},
|
||
daily,
|
||
}), now, false)
|
||
expect(wins).toHaveLength(3)
|
||
expect(wins.map(s => s.text)).toEqual([
|
||
'Cache hit at 85%, most prompts reuse cache',
|
||
'82% one-shot, edits land first try',
|
||
'Spend down 75% vs last 7 days',
|
||
])
|
||
})
|
||
|
||
it('renders the three-column Signals card with optimize findings under Improvements', async () => {
|
||
const now = new Date()
|
||
const payload = signalsPayload(now, {
|
||
current: {
|
||
cacheHitPercent: 85,
|
||
oneShotRate: 0.82,
|
||
localModelSavings: { totalUSD: 15, calls: 4, byModel: [], byProvider: [] },
|
||
},
|
||
optimize: {
|
||
findingCount: 1,
|
||
savingsUSD: 12,
|
||
topFindings: [{ title: 'Trim CLAUDE.md preamble', impact: 'high', savingsUSD: 12 }],
|
||
},
|
||
})
|
||
|
||
render(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)
|
||
|
||
const signals = await screen.findByLabelText('Coaching signals')
|
||
const wins = within(signals).getByText('Wins').closest('.ov-signal-group') as HTMLElement
|
||
expect(within(wins).getByText(/Cache hit at 85%/)).toBeInTheDocument()
|
||
const improvements = within(signals).getByText('Improvements').closest('.ov-signal-group') as HTMLElement
|
||
expect(within(improvements).getByText('Trim CLAUDE.md preamble')).toBeInTheDocument()
|
||
expect(within(improvements).getByText('$12.00')).toBeInTheDocument()
|
||
})
|
||
|
||
it('renders no Signals card when every group is empty', async () => {
|
||
const now = new Date()
|
||
const payload = signalsPayload(now, {
|
||
current: { cacheHitPercent: 60, oneShotRate: 0.6 },
|
||
daily: [],
|
||
optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
|
||
})
|
||
|
||
render(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)
|
||
|
||
expect(await screen.findByText('Daily spend')).toBeInTheDocument()
|
||
expect(screen.queryByLabelText('Coaching signals')).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('renders section costs in the active non-USD currency (rate applied once, symbol swapped)', async () => {
|
||
setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 })
|
||
const now = new Date()
|
||
getOverview.mockResolvedValue(makePayload(now))
|
||
|
||
render(<Overview period="30days" provider="all" />)
|
||
|
||
// Cost per outcome sources raw-USD yield values: $/commit = 150/6 = 25 → €22.50,
|
||
// $/productive session = 120/3 = 40 → €36.00 (rate applied exactly once).
|
||
const outcome = (await screen.findByText('Cost per outcome')).closest('.ov-panel') as HTMLElement
|
||
expect(within(outcome).getByText('€22.50')).toBeInTheDocument()
|
||
expect(within(outcome).getByText('€36.00')).toBeInTheDocument()
|
||
})
|
||
})
|
||
|
||
type WorkflowOverrides = {
|
||
workflow?: MenubarPayload['current']['workflow']
|
||
topReworkedFiles?: MenubarPayload['current']['topReworkedFiles']
|
||
pricingCoverage?: MenubarPayload['current']['pricingCoverage']
|
||
}
|
||
|
||
function workflowPayload(now: Date, over: WorkflowOverrides): MenubarPayload {
|
||
const base = makePayload(now)
|
||
return { ...base, current: { ...base.current, ...over } }
|
||
}
|
||
|
||
describe('Overview workflow card', () => {
|
||
beforeEach(() => {
|
||
setActiveCurrency({ code: 'USD', symbol: '$', rate: 1 })
|
||
getOverview.mockReset()
|
||
getActReport.mockReset()
|
||
getYield.mockReset()
|
||
getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
|
||
getYield.mockResolvedValue(makeYieldReport())
|
||
})
|
||
afterEach(() => vi.useRealTimers())
|
||
|
||
function workflowRegion(): HTMLElement {
|
||
return screen.getByRole('heading', { name: 'Workflow' }).closest('.ov-workflow-widget') as HTMLElement
|
||
}
|
||
|
||
it('renders correction rate, time to first edit, top rework, coverage chip, and a coaching note', async () => {
|
||
const now = new Date()
|
||
getOverview.mockResolvedValue(workflowPayload(now, {
|
||
workflow: { corrections: 7, correctionRate: 0.2, medianTimeToFirstEditMs: 45_000 },
|
||
topReworkedFiles: [{ path: 'parser.ts', sessions: 4, edits: 12 }],
|
||
pricingCoverage: 0.92,
|
||
}))
|
||
|
||
render(<Overview period="30days" provider="all" />)
|
||
|
||
const card = await waitFor(() => workflowRegion())
|
||
expect(within(card).getByText('Correction rate')).toBeInTheDocument()
|
||
expect(within(card).getByText('20%')).toBeInTheDocument()
|
||
expect(within(card).getByText('7 corrections')).toBeInTheDocument()
|
||
expect(within(card).getByText('Time to first edit')).toBeInTheDocument()
|
||
// Under 60s renders as seconds, not minutes.
|
||
expect(within(card).getByText('45s')).toBeInTheDocument()
|
||
expect(within(card).getByText(/Top rework:/)).toHaveTextContent('Top rework: parser.ts · 4 sessions · 12 edits')
|
||
// pricingCoverage 0.92 → a "92% priced" caveat chip.
|
||
expect(within(card).getByText('92% priced')).toBeInTheDocument()
|
||
// Corrections clears its bar first, so its coaching line wins.
|
||
expect(within(card).getByText(/You corrected the assistant on 20% of prompts \(7 times\)/)).toBeInTheDocument()
|
||
})
|
||
|
||
it('does not render at all when the payload carries no workflow signal', async () => {
|
||
const now = new Date()
|
||
// makePayload omits workflow/topReworkedFiles/pricingCoverage entirely.
|
||
getOverview.mockResolvedValue(makePayload(now))
|
||
|
||
render(<Overview period="30days" provider="all" />)
|
||
|
||
await screen.findByText('$312.40')
|
||
expect(screen.queryByRole('heading', { name: 'Workflow' })).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('stays hidden when workflow exists but every metric is empty', async () => {
|
||
const now = new Date()
|
||
getOverview.mockResolvedValue(workflowPayload(now, {
|
||
workflow: { corrections: 0, correctionRate: null, medianTimeToFirstEditMs: null },
|
||
topReworkedFiles: [],
|
||
pricingCoverage: 1,
|
||
}))
|
||
|
||
render(<Overview period="30days" provider="all" />)
|
||
|
||
await screen.findByText('$312.40')
|
||
expect(screen.queryByRole('heading', { name: 'Workflow' })).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('picks the churn note and formats minutes when corrections are below the bar', async () => {
|
||
const now = new Date()
|
||
getOverview.mockResolvedValue(workflowPayload(now, {
|
||
workflow: { corrections: 1, correctionRate: 0.05, medianTimeToFirstEditMs: 8 * 60 * 1000 },
|
||
topReworkedFiles: [{ path: 'router.ts', sessions: 5, edits: 30 }],
|
||
pricingCoverage: null,
|
||
}))
|
||
|
||
render(<Overview period="30days" provider="all" />)
|
||
|
||
const card = await waitFor(() => workflowRegion())
|
||
// >= 60s renders as whole minutes.
|
||
expect(within(card).getByText('8m')).toBeInTheDocument()
|
||
// Corrections (5%) is below 0.15, so the churn note wins over TTFE.
|
||
expect(within(card).getByText(/router\.ts was reworked across 5 sessions \(30 edits\)/)).toBeInTheDocument()
|
||
// pricingCoverage null → no chip.
|
||
expect(within(card).queryByText(/priced/)).not.toBeInTheDocument()
|
||
})
|
||
|
||
it('falls back to a neutral caption and hides the chip at full coverage when no note fires', async () => {
|
||
const now = new Date()
|
||
getOverview.mockResolvedValue(workflowPayload(now, {
|
||
workflow: { corrections: 1, correctionRate: 0.05, medianTimeToFirstEditMs: 30_000 },
|
||
topReworkedFiles: [{ path: 'small.ts', sessions: 1, edits: 2 }],
|
||
pricingCoverage: 1,
|
||
}))
|
||
|
||
render(<Overview period="30days" provider="all" />)
|
||
|
||
const card = await waitFor(() => workflowRegion())
|
||
expect(within(card).getByText('Corrections, first-edit latency, and file churn across your sessions.')).toBeInTheDocument()
|
||
expect(within(card).queryByText(/priced/)).not.toBeInTheDocument()
|
||
})
|
||
})
|