// @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 { 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>(), getActReport: vi.fn<() => Promise>(), getYield: vi.fn<(period: string, provider: string) => Promise>(), })) vi.mock('../lib/ipc', async orig => { const actual = await orig() 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 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() // 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() 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() 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() 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() 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() // 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() // 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() 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() 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() 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() // 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() 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() 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 = { data: makePayload(now), error: { kind: 'nonzero', message: 'codeburn exited 1' }, loading: false, switching: false, lastSuccessAt: Date.now(), refresh: vi.fn(), } render() 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() 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() 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() // 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() 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() 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() 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() 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() 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() }) })