Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions src/report/aggregate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,16 @@ test('builds a cost estimate from human merges and reviews, excluding bot merges
expect(bundle.costEstimate.humanMergeCount).toBe(100);
expect(bundle.costEstimate.humanReviewCount).toBe(0);
expect(bundle.costEstimate.hourlyRateUsd).toBe(200);
expect(bundle.costEstimate.minutesPerPr).toBe(12);
// 100 actions × 12 min × $200/hr / 60 = $4000 in window
expect(bundle.costEstimate.windowCostUsd).toBe(4000);
// ~$1,352/month over 90 days (window × 30.44/90)
expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(1300);
expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(1400);
expect(bundle.costEstimate.minutesPerPr).toBe(10);
// 100 actions × 10 min × $200/hr / 60 = $3333 in window
expect(bundle.costEstimate.windowCostUsd).toBe(3333);
// ~$1,126/month over 90 days (window × 30.44/90)
expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(1100);
expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(1150);
expect(bundle.costEstimate.annualCostUsd).toBe(bundle.costEstimate.monthlyCostUsd * 12);
expect(bundle.costEstimate.savingsScenarios.map((s) => s.autoMergeRate)).toEqual([0.5, 0.6, 0.7, 0.8]);
expect(bundle.costEstimate.savingsScenarios.map((s) => s.autoMergeRate)).toEqual([
0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9,
]);
expect(bundle.costEstimate.savingsScenarios[0]?.annualSavingsUsd).toBe(
(bundle.costEstimate.savingsScenarios[0]?.monthlySavingsUsd ?? 0) * 12,
);
Expand Down
4 changes: 2 additions & 2 deletions src/report/costFormulas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
// so it bundles cleanly into both targets.

export const ASSUMED_HOURLY_RATE_USD = 200;
export const ASSUMED_MIN_PER_PR = 12;
export const ASSUMED_MIN_PER_PR = 10;

export const AUTO_MERGE_SCENARIO_RATES = [0.5, 0.6, 0.7, 0.8] as const;
export const AUTO_MERGE_SCENARIO_RATES = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] as const;

const DAYS_PER_MONTH = 365 / 12;

Expand Down
6 changes: 3 additions & 3 deletions src/report/testFactories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,14 @@ export const stalledSignals = Factory.define<StalledSignals>(() => ({
// Cost figures derive from the real defaults and formulas so the fixtures track
// production whenever the assumptions move, rather than restating stale literals.
const COST_WINDOW_DAYS = 90;
const HUMAN_MERGE_COUNT = 150;
const HUMAN_MERGE_COUNT = 288;
const HUMAN_REVIEW_COUNT = 12;

export const people = Factory.define<People>(() => ({
mergers: derivePersonCosts(
[
{ login: 'alice', count: 90 },
{ login: 'bob', count: 60 },
{ login: 'alice', count: 180 },
{ login: 'bob', count: 108 },
],
COST_WINDOW_DAYS,
ASSUMED_MIN_PER_PR,
Expand Down
117 changes: 74 additions & 43 deletions src/report/web/App.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/rea
import { afterEach, describe, expect, it } from 'vitest';
import type { Analytics } from '../../Analytics.ts';
import { cveExposureOk, embeddedReportData } from '../testFactories.ts';
import { automatedStoryTestIds } from './acts/AutomatedStory.tsx';
import { automatedStoryCopy, automatedStoryTestIds } from './acts/AutomatedStory.tsx';
import { callToActionCopy, callToActionTestIds } from './acts/CallToAction.tsx';
import { costStoryCopy, costStoryTestIds } from './acts/CostStory.tsx';
import { methodologyAppendixTestIds } from './acts/MethodologyAppendix.tsx';
Expand All @@ -23,55 +23,80 @@ describe('App report shell', () => {
it('renders the headline annual cost from the embedded data', () => {
renderReport();

expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$26,280/year');
expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('50 engineer-hours/quarter');
expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent(verdictCopy.costLeadIn);
expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent(verdictCopy.costTrailer);
// The headline clarifies it excludes the open backlog, which lives in its own section.
expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent('not including the 102 still open');
expect(screen.getByTestId(assumptionInputTestIds.container)).toBeInTheDocument();
expect(screen.getByTestId(verdictTestIds.section)).not.toHaveTextContent('Showing savings as time');
expect(screen.queryByTestId(assumptionInputTestIds.hourlyRateUsd)).not.toBeInTheDocument();
});

it('recalculates the headline cost and comparison cards when assumptions change', () => {
renderReport();
const assumptions = screen.getByTestId(assumptionInputTestIds.container);

fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.hourlyRate), {
target: { value: '300' },
fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), {
target: { value: '12' },
});

expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('$39,420/year');
expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('$39,420/yr');
// "Today" mirrors the headline; "PatchWave savings" is the recovered cost at the default 65% share.
expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('$39,420/yr');
expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$25,623/yr');
expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('60 engineer-hours/quarter');
expect(screen.getByTestId(costStoryTestIds.windowCost)).toHaveTextContent('60 hrs');
expect(screen.getByTestId(costStoryTestIds.monthlyCost)).toHaveTextContent('20 hrs/mo');
expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('60 hrs/qtr');
// The automation comparison defaults to recovered engineer hours per quarter.
expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('~60 hrs/qtr');
expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('~36 hrs/qtr');
});

it('toggles report savings from time to cost', () => {
renderReport();
const assumptions = screen.getByTestId(assumptionInputTestIds.container);

fireEvent.click(within(assumptions).getByTestId(assumptionInputTestIds.displayCost));

expect(within(assumptions).getByTestId(assumptionInputTestIds.hourlyRateUsd)).toBeInTheDocument();
expect(screen.getByTestId(verdictTestIds.section)).not.toHaveTextContent('Showing savings as cost');
expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('~$40,560/year');
expect(screen.getByTestId(costStoryTestIds.windowCost)).toHaveTextContent('$10,000');
expect(screen.getByTestId(costStoryTestIds.monthlyCost)).toHaveTextContent('$3,380/mo');
expect(screen.getByTestId(costStoryTestIds.annualCost)).toHaveTextContent('$40,560/yr');
expect(screen.getByTestId(automatedStoryTestIds.todayCost)).toHaveTextContent('$40,560/yr');
expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$24,336/yr');

const table = screen.getByTestId(costStoryTestIds.peopleTable);
expect(within(table).getByRole('columnheader', { name: 'Cost over last 90 days' })).toBeInTheDocument();
expect(within(table).getByText('alice').closest('tr')).toHaveTextContent('$6,400');
});

it('allows replacing an assumption value by clearing and typing', () => {
renderReport();
const assumptions = screen.getByTestId(assumptionInputTestIds.container);
const hourlyRateInput = within(assumptions).getByTestId(assumptionInputTestIds.hourlyRate);
const minutesInput = within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr);

fireEvent.focus(hourlyRateInput);
fireEvent.change(hourlyRateInput, { target: { value: '' } });
expect(hourlyRateInput).toHaveValue('');
fireEvent.focus(minutesInput);
fireEvent.change(minutesInput, { target: { value: '' } });
expect(minutesInput).toHaveValue('');

fireEvent.change(hourlyRateInput, { target: { value: '275' } });
fireEvent.blur(hourlyRateInput);
fireEvent.change(minutesInput, { target: { value: '12' } });
fireEvent.blur(minutesInput);

expect(hourlyRateInput).toHaveValue('275');
expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('~$36,132/year');
expect(minutesInput).toHaveValue('12');
expect(screen.getByTestId(verdictTestIds.annualCost)).toHaveTextContent('60 engineer-hours/quarter');
});

it('recalculates the PatchWave savings card when the auto-merge share changes', () => {
renderReport();

// Default 65% share starts in the middle of the modeled range.
expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('65%');
expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$17,082/yr');
// Default 60% share matches PatchWave's public calculator assumption.
expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('60%');
expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('~30 hrs/qtr');

fireEvent.change(screen.getByTestId(automatedStoryTestIds.shareSlider), { target: { value: '50' } });

expect(screen.getByTestId(automatedStoryTestIds.delta)).toHaveTextContent('50%');
expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$13,140/yr');
expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('~25 hrs/qtr');
});

it('lists footnotes in ascending first-appearance order', () => {
Expand Down Expand Up @@ -187,8 +212,13 @@ describe('App report shell', () => {
expect(screen.getByTestId(openPrAgeStoryTestIds.section)).toHaveTextContent(openPrAgeStoryCopy.heading);
expect(screen.getByTestId(riskStoryTestIds.section)).toHaveTextContent(riskStoryCopy.eyebrow);
expect(screen.getByTestId(callToActionTestIds.section)).toHaveTextContent(callToActionCopy.heading);
expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveTextContent(verdictCopy.primaryCta);
expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveAttribute('data-variant', 'default');
expect(screen.getByTestId(automatedStoryTestIds.secondaryCta)).toHaveTextContent(automatedStoryCopy.secondaryCta);
expect(screen.getByTestId(automatedStoryTestIds.secondaryCta)).toHaveAttribute('data-variant', 'secondary');
expect(
screen
.getByTestId(automatedStoryTestIds.waitlistCta)
.compareDocumentPosition(screen.getByTestId(automatedStoryTestIds.secondaryCta)),
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(screen.getByTestId(callToActionTestIds.cta)).toHaveTextContent(callToActionCopy.ctaLabel);

fireEvent.click(screen.getByText('How this report was calculated'));
Expand All @@ -206,12 +236,12 @@ describe('App report shell', () => {
renderReport();

const table = screen.getByTestId(costStoryTestIds.peopleTable);
expect(within(table).getByRole('columnheader', { name: 'Cost over last 90 days' })).toBeInTheDocument();
expect(within(table).getByRole('columnheader', { name: 'Time over last 90 days' })).toBeInTheDocument();
const aliceCells = within(table).getAllByText('alice');
expect(aliceCells).toHaveLength(1);
// alice merged and reviewed, so her two activity rows collapse into one combined row.
const aliceRow = aliceCells[0]?.closest('tr');
expect(aliceRow).toHaveTextContent('90');
expect(aliceRow).toHaveTextContent('180');
expect(aliceRow).toHaveTextContent('merged');
expect(aliceRow).toHaveTextContent('12');
expect(aliceRow).toHaveTextContent('reviewed');
Expand Down Expand Up @@ -240,7 +270,7 @@ describe('App report shell', () => {
expect(within(table).getByText('person-6')).toBeInTheDocument();
});

it('reworks per-person review costs when the minutes-per-PR assumption changes', () => {
it('reworks per-person review time when the minutes-per-PR assumption changes', () => {
renderReport({
people: {
mergers: [],
Expand All @@ -250,20 +280,20 @@ describe('App report shell', () => {
});

const table = screen.getByTestId(costStoryTestIds.peopleTable);
// 10 reviews x 12 min x $200/hr / 60 = $400 in window at the defaults.
expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$400');
// 10 reviews x 10 min / 60 = ~2 hours in window at the defaults.
expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('2');

const assumptions = screen.getByTestId(assumptionInputTestIds.container);
fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), {
target: { value: '10' },
target: { value: '20' },
});

// Reviews ride the same minutes-per-PR slider as merges, so editing it reworks the cost:
// 10 reviews x 10 min x $200/hr / 60 = $333.
expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('$333');
// Reviews ride the same minutes-per-PR slider as merges, so editing it reworks the time:
// 10 reviews x 20 min / 60 = ~3 hours.
expect(within(table).getByText('carol').closest('tr')).toHaveTextContent('3');
});

it('reworks the raw-data per-person costs when an assumption changes', () => {
it('keeps raw-data people rows count-only when an assumption changes', () => {
renderReport({
people: {
mergers: [],
Expand All @@ -275,15 +305,16 @@ describe('App report shell', () => {
// The assumptions control lives in the hero, so it stays editable regardless of the appendix tab.
const assumptions = screen.getByTestId(assumptionInputTestIds.container);
fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), {
target: { value: '10' },
target: { value: '20' },
});

fireEvent.click(screen.getByText('How this report was calculated'));
fireEvent.click(screen.getByRole('tab', { name: 'Raw data' }));

// 10 reviews x 10 min x $200/hr / 60 = $333 window, annualized to $1,351/yr.
const rawData = screen.getByTestId(methodologyAppendixTestIds.rawData);
expect(within(rawData).getByText('carol').closest('li')).toHaveTextContent('$1,351/yr');
const row = within(rawData).getByText('carol').closest('li');
expect(row).toHaveTextContent('10 reviewed');
expect(row).not.toHaveTextContent('$');
});

it('renders the open PR age buckets as a separate section with count-only rows', () => {
Expand Down Expand Up @@ -367,15 +398,15 @@ describe('App analytics', () => {
cleanup();
});

it('captures cta_clicked when the verdict primary CTA is clicked', () => {
it('captures cta_clicked when the automated story secondary CTA is clicked', () => {
const { analytics, events } = createFakeAnalytics();
renderWithAnalytics(analytics);

const restore = suppressNavigation();
fireEvent.click(screen.getByTestId(verdictTestIds.primaryCta));
fireEvent.click(screen.getByTestId(automatedStoryTestIds.secondaryCta));
restore();

expect(events).toContainEqual({ event: 'cta_clicked', properties: { which: 'verdict_primary' } });
expect(events).toContainEqual({ event: 'cta_clicked', properties: { which: 'automated_story_secondary' } });
});

it('captures cta_clicked when the call-to-action CTA is clicked', () => {
Expand All @@ -393,14 +424,14 @@ describe('App analytics', () => {
const { analytics, events } = createFakeAnalytics();
renderWithAnalytics(analytics);

const input = screen.getAllByTestId(assumptionInputTestIds.hourlyRate)[0];
if (!input) throw new Error('missing hourly rate input');
fireEvent.change(input, { target: { value: '275' } });
const input = screen.getAllByTestId(assumptionInputTestIds.minutesPerPr)[0];
if (!input) throw new Error('missing minutes per PR input');
fireEvent.change(input, { target: { value: '12' } });
fireEvent.blur(input);

expect(events).toContainEqual({
event: 'assumption_changed',
properties: { field: 'hourly_rate', value: 275 },
properties: { field: 'minutes_per_pr', value: 12 },
});
});
});
Loading