diff --git a/src/__tests__/dashboard-score-banner.unit.test.tsx b/src/__tests__/dashboard-score-banner.unit.test.tsx index 01bf8ae..d5ff4b6 100644 --- a/src/__tests__/dashboard-score-banner.unit.test.tsx +++ b/src/__tests__/dashboard-score-banner.unit.test.tsx @@ -377,7 +377,7 @@ describe('ScoreBanner headline cascade', () => { expect(lastFrame()).toContain('3 leaks this period'); }); - it('headline tier 3 — loops > 100 with cost estimate', () => { + it('headline tier 3 — loops > 100, counts and no cost estimate', () => { const loops = Array.from({ length: 150 }, () => ({ toolName: 'Edit', commandPreview: '/x', @@ -399,10 +399,20 @@ describe('ScoreBanner headline cascade', () => { filtered={filtered} /> ); - expect(lastFrame()).toContain('150 loops'); - expect(lastFrame()).toContain('wasted'); - // 150 loops * count 10 * COST_PER_LOOP_ITER_USD (0.006) = $9 → "$9.00" - expect(lastFrame()).toContain('$9.00'); + // Was: `150 loops · ~$9.00 wasted`, from Σ(count) × COST_PER_LOOP_ITER_USD. + // Wrong three ways — it billed the first legitimate call, counted + // long-iteration findings as loops, and used a constant measured ~170x + // low. Two inflations and one deflation landed on a believable number + // while `node9 scan` reported five times more from the same data. + // + // The headline now states what it counted. Dollars live on the one screen + // that prices them per session. + expect(lastFrame()).toContain('150 repeated patterns'); + expect(lastFrame()).not.toContain('wasted'); + // Scoped to the headline: the banner has its own spend indicator, so a + // bare "no $ anywhere" assertion would fail on unrelated output. + const headline = (lastFrame() ?? '').split('📌')[1]?.split('💰')[0] ?? ''; + expect(headline).not.toContain('$'); }); it('headline tier 4 — exposed blast paths (when no leaks/loops/early-secrets)', () => { diff --git a/src/__tests__/report-headline.unit.test.ts b/src/__tests__/report-headline.unit.test.ts new file mode 100644 index 0000000..b9fe9f7 --- /dev/null +++ b/src/__tests__/report-headline.unit.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { computeHeadline } from '../tui/dashboard/views/report/index.js'; +import { EMPTY_FILTERED_SCAN } from '../tui/dashboard/views/report/derive.js'; +import type { LoopFinding } from '../cli/commands/scan'; + +// The headline is the first sentence a user reads on the Report screen. It +// used to price loops itself and got three things wrong at once, landing on a +// plausible $9.73 while `node9 scan` said $48.66 from the same data. + +const loop = (over: Partial = {}): LoopFinding => ({ + toolName: 'Edit', + commandPreview: '/tmp/x.ts', + count: 50, + timestamp: '2026-09-01T00:00:00Z', + project: 'p', + sessionId: 's1', + agent: 'claude', + kind: 'loop', + ...over, +}); + +const ready = { status: 'ready' } as Parameters[0]; + +const scan = (loops: LoopFinding[]) => ({ + ...EMPTY_FILTERED_SCAN, + loops, +}); + +describe('computeHeadline — loops', () => { + it('reports counts and never a dollar figure', () => { + const h = computeHeadline(ready, scan(Array.from({ length: 150 }, () => loop())), null); + expect(h?.text).not.toMatch(/\$/); + }); + + it('separates sustained work from genuinely stuck patterns', () => { + // 120 long-iteration + 30 real. The old line called all 150 "loops" and + // billed every iteration of all of them. + const loops = [ + ...Array.from({ length: 120 }, () => loop({ kind: 'long-iteration' })), + ...Array.from({ length: 30 }, () => loop({ kind: 'loop' })), + ]; + const h = computeHeadline(ready, scan(loops), null); + expect(h?.text).toContain('150'); + expect(h?.text).toContain('30'); + }); + + it('says zero stuck when every finding is sustained work', () => { + const loops = Array.from({ length: 150 }, () => loop({ kind: 'long-iteration' })); + const h = computeHeadline(ready, scan(loops), null); + expect(h?.text).toMatch(/\b0\b/); + }); + + it('counts a finding with no kind as stuck, matching the rest of the codebase', () => { + // `kind` is optional for legacy data; excluding those would under-report, + // which is the direction this product keeps failing in. + const legacy = loop(); + delete (legacy as { kind?: unknown }).kind; + const h = computeHeadline(ready, scan(Array.from({ length: 150 }, () => legacy)), null); + expect(h?.text).toContain('150'); + }); + + it('leaves the >100 threshold alone', () => { + const under = computeHeadline(ready, scan(Array.from({ length: 100 }, () => loop())), null); + const over = computeHeadline(ready, scan(Array.from({ length: 101 }, () => loop())), null); + expect(under?.text ?? '').not.toContain('repeated patterns'); + expect(over?.text).toContain('repeated patterns'); + }); + + it('still yields to leaks, which outrank loops', () => { + const h = computeHeadline( + ready, + { + ...scan(Array.from({ length: 150 }, () => loop())), + leaks: [{} as never], + }, + null + ); + expect(h?.text).toContain('leak'); + }); +}); diff --git a/src/tui/dashboard/views/report/index.tsx b/src/tui/dashboard/views/report/index.tsx index c9115ad..2f1ff10 100644 --- a/src/tui/dashboard/views/report/index.tsx +++ b/src/tui/dashboard/views/report/index.tsx @@ -19,7 +19,6 @@ import { COL } from '../../panels.js'; import { computeProtection } from '../../data.js'; import type { BlastSnapshot, ReportPeriod, ScanCache, ShieldStatus } from '../../types.js'; import type { AggregateResult } from '../../../../cli/aggregate/report-audit.js'; -import { COST_PER_LOOP_ITER_USD } from '@node9/policy-engine'; import { Protection } from './panels/Protection.js'; import { Cost } from './panels/Cost.js'; @@ -295,7 +294,7 @@ interface Headline { /** Priority cascade — first match wins. Returns null while there's no * data to assess (e.g. scan still loading and audit/blast both empty). */ -function computeHeadline( +export function computeHeadline( scanCache: ScanCache, filtered: FilteredScan, blast: BlastSnapshot | null @@ -326,9 +325,25 @@ function computeHeadline( }; } if (filtered.loops.length > 100) { - const wasted = filtered.loops.reduce((s, l) => s + (l.count ?? 0) * COST_PER_LOOP_ITER_USD, 0); + // Counts, no dollars. This line used to read + // `Σ(count) × COST_PER_LOOP_ITER_USD`, which was wrong three ways at once: + // it priced the first legitimate call as waste, it counted + // `long-iteration` findings (sustained work on one file) as loops, and it + // used the flat constant measured at ~170x too low. Two errors inflated + // and one deflated, landing on a plausible number — $9.73 against the + // $48.66 `node9 scan` reports from the same data. A number that looks + // reasonable and is built from three mistakes gets believed. + // + // Priced correctly needs per-session rates (computeLoopWaste), which this + // screen has no access to. Rather than thread that through three layers + // for one headline, the money lives on the one screen that computes it + // properly. A third dollar figure is a third chance to disagree with + // itself. + // + // "repeated patterns", not "loops": most of these are not loops. + const stuck = filtered.loops.filter((l) => l.kind !== 'long-iteration').length; return { - text: `📌 ${filtered.loops.length} loops · ~${fmtCost(wasted)} wasted`, + text: `📌 ${filtered.loops.length} repeated patterns · ${stuck} stuck`, color: 'yellow', }; }