From 38a8f8d4653ae29272182b800c4afb53e067e707 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Mon, 27 Apr 2026 21:43:43 +0200 Subject: [PATCH 1/7] fix(budget): use filtered available funds in CostBreakdownTable - Compute filteredAvailableFunds from selected budget sources only - Update Available Funds row display to use filtered value - Update Remaining Budget rows (Cost & Net columns) to use filtered funds - Update unused sum variable to use filtered funds for consistency - Hide deselected source rows in print via display: none Fixes budget calculations when budget sources are deselected in the cost breakdown. Co-Authored-By: Claude frontend-developer (Haiku 4.5) --- .../CostBreakdownTable.module.css | 5 +++++ .../CostBreakdownTable/CostBreakdownTable.tsx | 21 ++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css b/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css index 48a24381..6cbfde22 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css @@ -659,6 +659,11 @@ .rowLevel3 td { border-bottom: 1pt dotted var(--color-border); } + + /* Hide deselected source rows in print */ + .rowSourceDetailToggle[aria-pressed='false'] { + display: none !important; + } } /* ============================================================ diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx index 3d44a883..2315ca57 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx @@ -785,9 +785,16 @@ export function CostBreakdownTable({ ); /** - * Sum = availableFunds - totalRawProjected + adjustedTotalPayback. + * Compute filtered available funds: sum of totalAmount for sources that are not deselected. */ - const sum = overview.availableFunds - totalRawProjected + adjustedTotalPayback; + const filteredAvailableFunds = budgetSources + .filter((s) => !deselectedSourceIds.has(s.id)) + .reduce((sum: number, s) => sum + s.totalAmount, 0); + + /** + * Sum = filteredAvailableFunds - totalRawProjected + adjustedTotalPayback. + */ + const sum = filteredAvailableFunds - totalRawProjected + adjustedTotalPayback; // Empty state: only show early-return empty state if there are NO sources configured AND no items. // If sources are configured (even if all deselected, which prunes items), render the full table @@ -1120,7 +1127,7 @@ export function CostBreakdownTable({ - {formatCurrency(overview.availableFunds)} + {formatCurrency(filteredAvailableFunds)} {deselectedSourceIds.size > 0 && ( {t('overview.costBreakdown.availableFundsFilter.activeFilterCaption', { @@ -1233,25 +1240,25 @@ export function CostBreakdownTable({ = 0 + filteredAvailableFunds - totalRawProjected >= 0 ? styles.valuePositive : styles.valueNegative } > - {formatCurrency(overview.availableFunds - totalRawProjected)} + {formatCurrency(filteredAvailableFunds - totalRawProjected)} = 0 + filteredAvailableFunds - totalRawProjected + adjustedTotalPayback >= 0 ? styles.valuePositive : styles.valueNegative } > {formatCurrency( - overview.availableFunds - totalRawProjected + adjustedTotalPayback, + filteredAvailableFunds - totalRawProjected + adjustedTotalPayback, )} From a999159d8892daf035e4f44dcbc3d1993408d656 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Mon, 27 Apr 2026 21:57:25 +0200 Subject: [PATCH 2/7] test(e2e): add filter-aware Available Funds, Remaining Budget, and print-hiding tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new test.describe block to budget-source-filter.spec.ts covering the CostBreakdownTable changes from fix/1366: filteredAvailableFunds computation and the @media print rule hiding deselected source rows. Scenario 1: Available Funds value updates when a source is deselected. Scenario 2: Remaining Budget Cost column updates when a source is deselected. Scenario 3 (AC #4): Available Funds restores to full total on re-select. Scenario 4 (AC #5): All sources deselected → Available Funds shows €0, not NaN. Scenario 5 (AC #8): Print media hides deselected source rows (display:none) and leaves selected source rows visible. Fixes #1366 Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) --- e2e/tests/budget/budget-source-filter.spec.ts | 438 ++++++++++++++++++ 1 file changed, 438 insertions(+) diff --git a/e2e/tests/budget/budget-source-filter.spec.ts b/e2e/tests/budget/budget-source-filter.spec.ts index 448c5102..6aedf344 100644 --- a/e2e/tests/budget/budget-source-filter.spec.ts +++ b/e2e/tests/budget/budget-source-filter.spec.ts @@ -2272,3 +2272,441 @@ test.describe('Responsive layout', { tag: '@responsive' }, () => { } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Filter-aware summary rows (Available Funds + Remaining Budget) +// AC refs: #4 (restore on re-select), #5 (zero sources), #8 (print hiding) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Filter-aware summary rows (Available Funds + Remaining Budget)', () => { + /** + * Build a breakdown with two real sources (Source A: 150 000, Source B: 100 000) + * and a deterministic projected cost (rawProjectedMin === rawProjectedMax === 20 000) + * so that "avg" perspective gives the same value as min and max. + * Unassigned has totalAmount=0 and does not affect the Available Funds total. + */ + function makeTwoSourceBreakdown() { + return { + workItems: { + areas: [ + { + areaId: 'area-main', + name: 'Main Area', + parentId: null, + color: '#3B82F6', + projectedMin: 20000, + projectedMax: 20000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 20000, + rawProjectedMax: 20000, + minSubsidyPayback: 0, + items: [ + { + workItemId: 'wi-main-1', + title: 'Main Work Item', + projectedMin: 20000, + projectedMax: 20000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 20000, + rawProjectedMax: 20000, + minSubsidyPayback: 0, + costDisplay: 'projected', + budgetLines: [ + { + id: 'line-a1', + description: 'Line A1', + plannedAmount: 10000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: SOURCE_A_ID, + }, + { + id: 'line-b1', + description: 'Line B1', + plannedAmount: 10000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: SOURCE_B_ID, + }, + ], + }, + ], + children: [], + }, + ], + totals: { + projectedMin: 20000, + projectedMax: 20000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 20000, + rawProjectedMax: 20000, + minSubsidyPayback: 0, + }, + }, + householdItems: { + areas: [], + totals: makeEmptyTotals(), + }, + subsidyAdjustments: [], + budgetSources: [ + { + id: SOURCE_A_ID, + name: 'Bank Loan', + totalAmount: 150000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }, + { + id: SOURCE_B_ID, + name: 'Equity', + totalAmount: 100000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }, + ], + }; + } + + /** + * Filtered breakdown returned when Source B (Equity) is deselected. + * Server prunes Source B lines — only Source A lines remain. + * budgetSources still contains both (server always returns unfiltered projectedMin/Max), + * so source toggle rows remain visible after the refetch. + */ + function makeTwoSourceBreakdownEquityDeselected() { + return { + workItems: { + areas: [ + { + areaId: 'area-main', + name: 'Main Area', + parentId: null, + color: '#3B82F6', + projectedMin: 10000, + projectedMax: 10000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 10000, + rawProjectedMax: 10000, + minSubsidyPayback: 0, + items: [ + { + workItemId: 'wi-main-1', + title: 'Main Work Item', + projectedMin: 10000, + projectedMax: 10000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 10000, + rawProjectedMax: 10000, + minSubsidyPayback: 0, + costDisplay: 'projected', + budgetLines: [ + { + id: 'line-a1', + description: 'Line A1', + plannedAmount: 10000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: SOURCE_A_ID, + }, + ], + }, + ], + children: [], + }, + ], + totals: { + projectedMin: 10000, + projectedMax: 10000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 10000, + rawProjectedMax: 10000, + minSubsidyPayback: 0, + }, + }, + householdItems: { + areas: [], + totals: makeEmptyTotals(), + }, + subsidyAdjustments: [], + // Server returns both sources regardless of filter + budgetSources: [ + { + id: SOURCE_A_ID, + name: 'Bank Loan', + totalAmount: 150000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }, + { + id: SOURCE_B_ID, + name: 'Equity', + totalAmount: 100000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }, + ], + }; + } + + test('Scenario 1 — Available Funds updates when a source is deselected', async ({ page }) => { + // Source A: totalAmount=150 000, Source B: totalAmount=100 000 → combined=250 000. + // After deselecting Source B (Equity): filteredAvailableFunds = 150 000 (Source A only). + const overviewPage = new BudgetOverviewPage(page); + const teardown = await mountOverviewRoutes( + page, + makeBudgetOverviewResponse(), + makeTwoSourceBreakdown(), + makeTwoSourceBreakdownEquityDeselected(), + ); + + try { + await overviewPage.goto(); + await overviewPage.waitForLoaded(); + + // Initial state: both sources selected → Available Funds = 250 000 + const afValue = overviewPage.availableFundsValue(); + await expect(afValue).toBeVisible(); + const initialText = await afValue.textContent(); + // Combined total must contain "250" (as in €250,000) + expect(initialText).toContain('250'); + + // Expand source rows + await overviewPage.availableFundsButton().click(); + + // Deselect Equity — register waitForResponse BEFORE the click + const refetchPromise = page.waitForResponse( + (resp) => + resp.url().includes('/api/budget/breakdown') && resp.url().includes('deselectedSources='), + ); + await overviewPage.sourceRow('Equity').click(); + await expect(overviewPage.sourceRow('Equity')).toHaveAttribute('aria-pressed', 'false'); + await refetchPromise; + + // Available Funds now shows only Source A total (150 000) + const filteredText = await afValue.textContent(); + expect(filteredText).toContain('150'); + // Must NOT still show 250 (combined) + expect(filteredText).not.toContain('250'); + } finally { + await teardown(); + } + }); + + test('Scenario 2 — Remaining Budget Cost updates when a source is deselected', async ({ + page, + }) => { + // Source A: totalAmount=150 000, Source B: totalAmount=100 000 + // projectedCost = 20 000 (rawProjectedMin=rawProjectedMax=20 000 → avg = 20 000) + // Initial Remaining Budget Cost = 250 000 - 20 000 = 230 000 + // After deselecting Source B (Equity): filteredAvailableFunds = 150 000 + // Remaining Budget Cost = 150 000 - 20 000 = 130 000 + const overviewPage = new BudgetOverviewPage(page); + const teardown = await mountOverviewRoutes( + page, + makeBudgetOverviewResponse(), + makeTwoSourceBreakdown(), + makeTwoSourceBreakdownEquityDeselected(), + ); + + try { + await overviewPage.goto(); + await overviewPage.waitForLoaded(); + + // Locate the "Remaining Budget" row by its label text + const remainingRow = overviewPage.costBreakdownCard + .getByRole('row') + .filter({ hasText: /remaining budget/i }); + + // Remaining Budget Cost cell (td index 1 = "Cost" column) + const costCell = remainingRow.locator('td').nth(1); + await expect(costCell).toBeVisible(); + const initialText = await costCell.textContent(); + // Should reflect 230 000 (250 000 - 20 000) + expect(initialText).toContain('230'); + + // Expand source rows + await overviewPage.availableFundsButton().click(); + + // Deselect Equity — register waitForResponse BEFORE the click + const refetchPromise = page.waitForResponse( + (resp) => + resp.url().includes('/api/budget/breakdown') && resp.url().includes('deselectedSources='), + ); + await overviewPage.sourceRow('Equity').click(); + await expect(overviewPage.sourceRow('Equity')).toHaveAttribute('aria-pressed', 'false'); + await refetchPromise; + + // Remaining Budget Cost = 150 000 - 20 000 = 130 000 + const filteredText = await costCell.textContent(); + expect(filteredText).toContain('130'); + expect(filteredText).not.toContain('230'); + } finally { + await teardown(); + } + }); + + test('Scenario 3 — Available Funds restores to full total on re-select (AC #4)', async ({ + page, + }) => { + // Start with Source B (Equity) deselected via URL — Available Funds = 150 000 (Source A only). + // Re-select Equity → refetch fires without deselectedSources → Available Funds = 250 000. + const overviewPage = new BudgetOverviewPage(page); + const teardown = await mountOverviewRoutes( + page, + makeBudgetOverviewResponse(), + makeTwoSourceBreakdown(), + makeTwoSourceBreakdownEquityDeselected(), + ); + + try { + // Navigate with Equity pre-deselected + await page.goto(`${BUDGET_OVERVIEW_ROUTE}?deselectedSources=${SOURCE_B_ID}`); + await overviewPage.waitForLoaded(); + + const afValue = overviewPage.availableFundsValue(); + await expect(afValue).toBeVisible(); + + // Pre-deselected state: Available Funds = 150 000 (Bank Loan only) + const deselectedText = await afValue.textContent(); + expect(deselectedText).toContain('150'); + + // Expand and re-select Equity + await overviewPage.availableFundsButton().click(); + await expect(overviewPage.sourceRow('Equity')).toHaveAttribute('aria-pressed', 'false'); + + const reSelectPromise = page.waitForResponse( + (resp) => + resp.url().includes('/api/budget/breakdown') && + !resp.url().includes('deselectedSources='), + ); + await overviewPage.sourceRow('Equity').click(); + await expect(overviewPage.sourceRow('Equity')).toHaveAttribute('aria-pressed', 'true'); + await reSelectPromise; + + // Available Funds restored to 250 000 (both sources selected) + const restoredText = await afValue.textContent(); + expect(restoredText).toContain('250'); + expect(restoredText).not.toContain('150'); + } finally { + await teardown(); + } + }); + + test('Scenario 4 — Zero sources selected: Available Funds shows €0 (AC #5)', async ({ + page, + }) => { + // When all sources are deselected, filteredAvailableFunds = 0. + // Server returns empty areas (all lines belong to deselected sources). + // The Available Funds row must show €0.00 — not NaN and not the stale combined value. + const overviewPage = new BudgetOverviewPage(page); + + // Use makeBreakdownSourceAOnly (single source: Bank Loan, totalAmount=150 000). + // Filtered response is makeFilteredEmptyBreakdown which has empty areas[]. + const teardown = await mountOverviewRoutes( + page, + makeBudgetOverviewResponse(), + makeBreakdownSourceAOnly(), + makeFilteredEmptyBreakdown(), + ); + + try { + await overviewPage.goto(); + await overviewPage.waitForLoaded(); + + await overviewPage.availableFundsButton().click(); + + // Deselect the only source (Bank Loan) + const refetchPromise = page.waitForResponse( + (resp) => + resp.url().includes('/api/budget/breakdown') && resp.url().includes('deselectedSources='), + ); + await overviewPage.sourceRow('Bank Loan').click(); + await expect(overviewPage.sourceRow('Bank Loan')).toHaveAttribute('aria-pressed', 'false'); + await refetchPromise; + + // Available Funds must show €0.00 — not NaN, not the stale 150 000 value + const afValue = overviewPage.availableFundsValue(); + const valueText = await afValue.textContent(); + // Must contain "0" as a number (e.g. "€0.00" or "€0,00") + expect(valueText).toMatch(/\b0\b|0,00|0\.00/); + // Must not contain "NaN" + expect(valueText).not.toContain('NaN'); + // Must not contain the stale 150 000 total + expect(valueText).not.toContain('150'); + } finally { + await teardown(); + } + }); + + test('Scenario 5 — Print: deselected source rows hidden, selected source rows visible (AC #8)', async ({ + page, + }) => { + // The @media print rule `.rowSourceDetailToggle[aria-pressed='false'] { display: none !important }` + // must hide deselected source rows in print and leave selected rows visible. + const overviewPage = new BudgetOverviewPage(page); + const teardown = await mountOverviewRoutes( + page, + makeBudgetOverviewResponse(), + makeTwoSourceBreakdown(), + makeTwoSourceBreakdownEquityDeselected(), + ); + + try { + await overviewPage.goto(); + await overviewPage.waitForLoaded(); + + // Expand Available Funds section to show source toggle rows + await overviewPage.availableFundsButton().click(); + await expect(overviewPage.sourceRow('Bank Loan')).toBeVisible(); + await expect(overviewPage.sourceRow('Equity')).toBeVisible(); + + // Deselect Equity — register waitForResponse BEFORE the click + const refetchPromise = page.waitForResponse( + (resp) => + resp.url().includes('/api/budget/breakdown') && resp.url().includes('deselectedSources='), + ); + await overviewPage.sourceRow('Equity').click(); + await expect(overviewPage.sourceRow('Equity')).toHaveAttribute('aria-pressed', 'false'); + await refetchPromise; + + // Switch to print media + await page.emulateMedia({ media: 'print' }); + + try { + // Deselected source row (Equity, aria-pressed='false') must be hidden by @media print + await expect(overviewPage.sourceRow('Equity')).toHaveCSS('display', 'none'); + + // Selected source row (Bank Loan, aria-pressed='true') must NOT be display:none + const bankLoanDisplay = await overviewPage + .sourceRow('Bank Loan') + .evaluate((el) => getComputedStyle(el).display); + expect(bankLoanDisplay).not.toBe('none'); + } finally { + // Restore screen media — must be in finally to prevent print-mode from leaking + // into subsequent tests running in the same worker. + await page.emulateMedia({ media: 'screen' }); + } + } finally { + await teardown(); + } + }); +}); From 16fa2331be9602410f185a4d45f7e3c4eae489c8 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Mon, 27 Apr 2026 22:03:08 +0200 Subject: [PATCH 3/7] test(budget): add filteredAvailableFunds unit tests for Available Funds and Remaining Budget rows Tests all three filter states for the bug fix (#1362): - All sources selected: filteredAvailableFunds = sum of all source totalAmounts - One source deselected: filteredAvailableFunds excludes deselected source amount - All sources deselected: filteredAvailableFunds = 0 (not unfiltered total) - Remaining Budget Cost and Net columns use filteredAvailableFunds in all states - 'unassigned' source with totalAmount=0 does not affect filteredAvailableFunds - Re-render with empty deselectedSourceIds restores unfiltered values Fixes #1362 Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) --- .../CostBreakdownTable.test.tsx | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx index 0218d17a..c5c9cb08 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx @@ -4030,3 +4030,426 @@ describe('Server-driven render path (#1360)', () => { expect(container.querySelector('table')).toBeInTheDocument(); }); }); + +// ── filteredAvailableFunds — Available Funds + Remaining Budget rows ────────── +// +// After the bug fix in #1362, the Available Funds row and Remaining Budget row +// use filteredAvailableFunds = sum of totalAmount for sources NOT in deselectedSourceIds, +// computed client-side from breakdown.budgetSources. These tests verify all three +// filter states: all selected, partial deselection, and all deselected. + +describe('filteredAvailableFunds — Available Funds row and Remaining Budget row (#1362)', () => { + /** + * Builds a two-source breakdown fixture for filter-aware summary row tests. + * + * Source A: id='src-a', name='Bank Loan', totalAmount=150000 + * Source B: id='src-b', name='Equity', totalAmount=100000 + * Total available funds (all selected): 250000 + * + * rawProjectedMin = rawProjectedMax = 50000 (so avg perspective = 50000 exactly, + * making Remaining Budget Cost = filteredAvailableFunds - 50000, deterministic). + * + * No subsidies → adjustedTotalPayback = 0. + */ + function buildTwoSourceBreakdown(): BudgetBreakdown { + return { + workItems: { + areas: [ + { + areaId: null, + name: 'Unassigned', + parentId: null, + color: null, + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 0, + items: [ + { + workItemId: 'wi-filter-1', + title: 'Foundation Work', + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 0, + costDisplay: 'projected', + budgetLines: [ + { + id: 'line-filter-1', + description: null, + plannedAmount: 50000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: 'src-a', + }, + ], + }, + ], + children: [], + }, + ], + totals: { + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 0, + }, + }, + householdItems: { + areas: [], + totals: { + projectedMin: 0, + projectedMax: 0, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 0, + rawProjectedMax: 0, + minSubsidyPayback: 0, + }, + }, + subsidyAdjustments: [], + budgetSources: [ + buildSourceSummary({ + id: 'src-a', + name: 'Bank Loan', + totalAmount: 150000, + projectedMin: 40000, + projectedMax: 60000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }), + buildSourceSummary({ + id: 'src-b', + name: 'Equity', + totalAmount: 100000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }), + ], + }; + } + + // Helper: get the Available Funds row + function getAvailFundsRow(): HTMLElement { + return screen.getByText('Available funds').closest('tr') as HTMLElement; + } + + // Helper: get the Remaining Budget row + function getRemainingBudgetRow(): HTMLElement { + return screen.getByRole('row', { name: /remaining budget/i }); + } + + // ── Scenario 1: All sources selected (deselectedSourceIds = empty set) ─────── + // filteredAvailableFunds = 150000 + 100000 = 250000 + + it('Available Funds row displays full sum of all source totalAmounts when deselectedSourceIds is empty (Scenario 1)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(), + }); + + const availFundsRow = getAvailFundsRow(); + const costCell = availFundsRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // filteredAvailableFunds = 150000 + 100000 = 250000 + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€250,000.00'); + }); + + // ── Scenario 2: One source deselected ──────────────────────────────────────── + // deselectedSourceIds = {'src-b'} → filteredAvailableFunds = 150000 only + + it('Available Funds row displays sum of selected sources only when one source is deselected (Scenario 2)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-b']), + }); + + const availFundsRow = getAvailFundsRow(); + const costCell = availFundsRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // filteredAvailableFunds = 150000 (src-b excluded) + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€150,000.00'); + // Must NOT contain the unfiltered total + expect(costCell!.textContent).not.toContain('€250,000.00'); + }); + + // ── Scenario 3: All sources deselected ──────────────────────────────────────── + // deselectedSourceIds = {'src-a', 'src-b'} → filteredAvailableFunds = 0 + + it('Available Funds row displays €0.00 when all sources are deselected (Scenario 3)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-a', 'src-b']), + }); + + const availFundsRow = getAvailFundsRow(); + const costCell = availFundsRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // filteredAvailableFunds = 0 + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€0.00'); + // Must NOT be NaN or undefined + expect(costCell!.textContent).not.toContain('NaN'); + expect(costCell!.textContent).not.toContain('undefined'); + // Must NOT show the unfiltered total + expect(costCell!.textContent).not.toContain('€250,000.00'); + }); + + // ── Scenario 4: Remaining Budget Cost column — all selected ────────────────── + // filteredAvailableFunds = 250000, totalRawProjected = 50000 (avg of 50000/50000) + // Remaining Budget Cost = 250000 - 50000 = 200000 + + it('Remaining Budget Cost column = filteredAvailableFunds - totalRawProjected when all sources selected (Scenario 4)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(), + }); + + const remainingRow = getRemainingBudgetRow(); + const costCell = remainingRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // 250000 - 50000 = 200000 + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€200,000.00'); + }); + + // ── Scenario 5: Remaining Budget Cost column — one source deselected ───────── + // deselectedSourceIds = {'src-b'} → filteredAvailableFunds = 150000 + // Remaining Budget Cost = 150000 - 50000 = 100000 (NOT 250000 - 50000 = 200000) + + it('Remaining Budget Cost column uses filteredAvailableFunds (not total) when one source is deselected (Scenario 5)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-b']), + }); + + const remainingRow = getRemainingBudgetRow(); + const costCell = remainingRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // filteredAvailableFunds (150000) - totalRawProjected (50000) = 100000 + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€100,000.00'); + // Must NOT use the unfiltered total: 250000 - 50000 = 200000 + expect(costCell!.textContent).not.toContain('€200,000.00'); + }); + + // ── Scenario 6: Remaining Budget Cost column — all sources deselected ──────── + // deselectedSourceIds = {'src-a', 'src-b'} → filteredAvailableFunds = 0 + // Remaining Budget Cost = 0 - 50000 = -50000 → negative, uses valueNegative CSS class + + it('Remaining Budget Cost column is negative when all sources are deselected (Scenario 6)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-a', 'src-b']), + }); + + const remainingRow = getRemainingBudgetRow(); + const costCell = remainingRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // 0 - 50000 = -50000: value is negative + const costSpan = costCell!.querySelector('span'); + expect(costSpan).not.toBeNull(); + // Span text should contain the cost amount and a minus sign + expect(costSpan!.textContent).toContain('50,000.00'); + expect(costSpan!.textContent).toContain('-'); + // Span should have the valueNegative class (filteredAvailableFunds - totalRawProjected < 0) + expect(costSpan).toHaveClass('valueNegative'); + }); + + // ── Scenario 7: Remaining Budget Net column uses filteredAvailableFunds ─────── + // Build breakdown with adjustedTotalPayback > 0 to verify it's included. + // deselectedSourceIds = {'src-b'} → filteredAvailableFunds = 150000 + // totalRawProjected = 50000, adjustedTotalPayback = 5000 (from wi subsidyPayback) + // Remaining Budget Net = 150000 - 50000 + 5000 = 105000 + + it('Remaining Budget Net column = filteredAvailableFunds - totalRawProjected + adjustedTotalPayback when source is deselected (Scenario 7)', () => { + // Use a breakdown with WI subsidy payback to make adjustedTotalPayback > 0 + const breakdown: BudgetBreakdown = { + workItems: { + areas: [ + { + areaId: null, + name: 'Unassigned', + parentId: null, + color: null, + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 5000, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 5000, + items: [ + { + workItemId: 'wi-payback-1', + title: 'Subsidized Work', + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 5000, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 5000, + costDisplay: 'projected', + budgetLines: [ + { + id: 'line-payback-1', + description: null, + plannedAmount: 50000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: 'src-a', + }, + ], + }, + ], + children: [], + }, + ], + totals: { + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 5000, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 5000, + }, + }, + householdItems: { + areas: [], + totals: { + projectedMin: 0, + projectedMax: 0, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 0, + rawProjectedMax: 0, + minSubsidyPayback: 0, + }, + }, + subsidyAdjustments: [], + budgetSources: [ + buildSourceSummary({ + id: 'src-a', + name: 'Bank Loan', + totalAmount: 150000, + projectedMin: 40000, + projectedMax: 60000, + subsidyPaybackMin: 5000, + subsidyPaybackMax: 5000, + }), + buildSourceSummary({ + id: 'src-b', + name: 'Equity', + totalAmount: 100000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }), + ], + }; + + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-b']), + }); + + const remainingRow = getRemainingBudgetRow(); + const netCell = remainingRow.querySelector('td[class*="colRemaining"]'); + expect(netCell).not.toBeNull(); + // filteredAvailableFunds (150000) - totalRawProjected (50000) + adjustedTotalPayback (5000) = 105000 + expect(netCell!.textContent?.replace(/\s+/g, '')).toContain('€105,000.00'); + }); + + // ── Scenario 8: unassigned source deselected — totalAmount=0, no effect ────── + // The 'unassigned' source has totalAmount=0. Deselecting it changes filteredAvailableFunds + // by zero (subtracts 0 from the sum), so the totals stay the same as with it selected. + + it('deselecting the unassigned source (totalAmount=0) does not change filteredAvailableFunds (Scenario 8)', () => { + const breakdown: BudgetBreakdown = { + ...buildTwoSourceBreakdown(), + budgetSources: [ + buildSourceSummary({ + id: 'src-a', + name: 'Bank Loan', + totalAmount: 150000, + projectedMin: 40000, + projectedMax: 60000, + }), + buildSourceSummary({ + id: 'unassigned', + name: 'Unassigned', + totalAmount: 0, + projectedMin: 0, + projectedMax: 0, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }), + ], + }; + + // Deselect unassigned (totalAmount=0) — Available Funds should still = 150000 + renderWithRouter(breakdown, buildOverview(150000), { + deselectedSourceIds: new Set(['unassigned']), + }); + + const availFundsRow = getAvailFundsRow(); + const costCell = availFundsRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // unassigned contributes 0, so filteredAvailableFunds = 150000 + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€150,000.00'); + }); + + // ── Scenario 9: toggling back to all-selected restores unfiltered values ────── + // First render with src-b deselected (filteredAvailableFunds=150000), then + // re-render with empty deselectedSourceIds (filteredAvailableFunds=250000). + + it('Available Funds and Remaining Budget restore to unfiltered values when deselectedSourceIds becomes empty (Scenario 9)', () => { + const breakdown = buildTwoSourceBreakdown(); + + // Phase 1: src-b deselected → filteredAvailableFunds = 150000 + const { rerender } = renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-b']), + }); + + const availFundsRow1 = getAvailFundsRow(); + const costCell1 = availFundsRow1.querySelector('td[class*="colBudget"]'); + expect(costCell1!.textContent?.replace(/\s+/g, '')).toContain('€150,000.00'); + + // Phase 2: re-render with no deselection → filteredAvailableFunds = 250000 + rerender( + + {}} + onSelectAllSources={() => {}} + /> + , + ); + + const availFundsRow2 = getAvailFundsRow(); + const costCell2 = availFundsRow2.querySelector('td[class*="colBudget"]'); + expect(costCell2!.textContent?.replace(/\s+/g, '')).toContain('€250,000.00'); + + // Remaining Budget Cost should also restore + const remainingRow = getRemainingBudgetRow(); + const remainingCostCell = remainingRow.querySelector('td[class*="colBudget"]'); + // 250000 - 50000 = 200000 + expect(remainingCostCell!.textContent?.replace(/\s+/g, '')).toContain('€200,000.00'); + }); +}); From 2054bea561423f9fe15b23453e832cce751f4e0d Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Mon, 27 Apr 2026 22:14:36 +0200 Subject: [PATCH 4/7] test(e2e): fix Remaining Budget arithmetic in Scenario 2 After deselecting Equity, the server returns a filtered breakdown whose wiTotals.rawProjectedMin/Max = 10 000 (not 20 000), so totalRawProjected drops to 10 000. The correct post-filter Remaining Budget Cost is therefore 150 000 - 10 000 = 140 000, not 130 000. Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) --- e2e/tests/budget/budget-source-filter.spec.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/e2e/tests/budget/budget-source-filter.spec.ts b/e2e/tests/budget/budget-source-filter.spec.ts index 6aedf344..bf14b296 100644 --- a/e2e/tests/budget/budget-source-filter.spec.ts +++ b/e2e/tests/budget/budget-source-filter.spec.ts @@ -2515,10 +2515,12 @@ test.describe('Filter-aware summary rows (Available Funds + Remaining Budget)', page, }) => { // Source A: totalAmount=150 000, Source B: totalAmount=100 000 - // projectedCost = 20 000 (rawProjectedMin=rawProjectedMax=20 000 → avg = 20 000) - // Initial Remaining Budget Cost = 250 000 - 20 000 = 230 000 - // After deselecting Source B (Equity): filteredAvailableFunds = 150 000 - // Remaining Budget Cost = 150 000 - 20 000 = 130 000 + // Initial (both selected): totalRawProjected = (20 000+20 000)/2 = 20 000 + // Remaining Budget Cost = 250 000 - 20 000 = 230 000 + // After deselecting Source B (Equity): server returns filtered breakdown with + // wiTotals.rawProjectedMin/Max = 10 000 → totalRawProjected = 10 000 + // filteredAvailableFunds = 150 000 (Bank Loan only) + // Remaining Budget Cost = 150 000 - 10 000 = 140 000 const overviewPage = new BudgetOverviewPage(page); const teardown = await mountOverviewRoutes( page, @@ -2555,9 +2557,9 @@ test.describe('Filter-aware summary rows (Available Funds + Remaining Budget)', await expect(overviewPage.sourceRow('Equity')).toHaveAttribute('aria-pressed', 'false'); await refetchPromise; - // Remaining Budget Cost = 150 000 - 20 000 = 130 000 + // Remaining Budget Cost = 150 000 - 10 000 = 140 000 const filteredText = await costCell.textContent(); - expect(filteredText).toContain('130'); + expect(filteredText).toContain('140'); expect(filteredText).not.toContain('230'); } finally { await teardown(); From 618ee8f23779aff3e864b6595f1c855eb03d2b55 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Mon, 27 Apr 2026 22:24:29 +0200 Subject: [PATCH 5/7] test(budget): fix two CostBreakdownTable test bugs broken by filteredAvailableFunds change (#1366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production change in #1366 replaced overview.availableFunds with filteredAvailableFunds (sum of non-deselected budgetSources) for the Available Funds row. Two existing tests had incorrect fixtures that assumed overview.availableFunds was the source of truth: 1. Test 16 "shows Available funds row with formatted currency value": rendered with budgetSources:[] so filteredAvailableFunds=0, not 50000. Fix: add a budgetSource with totalAmount=50000 to the breakdown fixture. 2. Scenario 23 "Remaining Budget row uses filteredAvailableFunds": src-1360-a had totalAmount=100000 but overview.availableFunds=200000, making the fixture internally inconsistent under the new computation. Fix: set src-1360-a.totalAmount=200000 so filteredAvailableFunds=200000, preserving the expected Remaining Budget value of €150,750.00. Also updated the test name and comment to reflect the new semantics. Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) --- .../CostBreakdownTable.test.tsx | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx index c5c9cb08..09702942 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx @@ -593,9 +593,15 @@ describe('CostBreakdownTable', () => { // ── 16. Summary rows show totals ────────────────────────────────────────── it('shows Available funds row with formatted currency value', () => { + // budgetSources must include a source with totalAmount=50000 so that + // filteredAvailableFunds (sum of non-deselected sources) equals €50,000.00. + const breakdown = { + ...buildBreakdownWithWI({ projectedMin: 800, projectedMax: 1200 }), + budgetSources: [buildSourceSummary({ id: 'src-test', totalAmount: 50000 })], + }; render( {}} @@ -3850,11 +3856,15 @@ describe('Server-driven render path (#1360)', () => { }, subsidyAdjustments: [], budgetSources: [ - // Source A: selected (not in deselectedSourceIds), has payback + // Source A: selected (not in deselectedSourceIds), has payback. + // totalAmount=200000 so that filteredAvailableFunds = 200000 (matching + // overview.availableFunds) — this keeps Scenario 23 internally consistent + // now that Available Funds uses sum-of-visible-sources rather than + // overview.availableFunds directly. buildSourceSummary({ id: 'src-1360-a', name: 'Green Fund', - totalAmount: 100000, + totalAmount: 200000, projectedMin: 40000, projectedMax: 60000, subsidyPaybackMin: 500, @@ -3933,26 +3943,25 @@ describe('Server-driven render path (#1360)', () => { expect(costCell!.textContent?.replace(/\s+/g, '')).toBe('-€50,000.00'); }); - // ── Scenario 23: Remaining Budget row uses overview.availableFunds (not filtered) ─ - // With deselectedSourceIds=new Set(['src-1360-b']) (hypothetical — src-1360-b not in breakdown - // because server already excluded it), the row still reads: - // overview.availableFunds - totalRawProjected + adjustedTotalPayback - // = 200000 - 50000 + 750 = 150750 - // The point: remaining = overview.availableFunds (not a filtered sub-amount). - - it('Remaining Budget row uses overview.availableFunds directly (Scenario 23)', () => { + // ── Scenario 23: Remaining Budget row uses filteredAvailableFunds ──────────── + // filteredAvailableFunds = sum of budgetSources not in deselectedSourceIds. + // With deselectedSourceIds=new Set(['src-1360-b']) (hypothetical — src-1360-b is + // not present in the breakdown so no source is filtered out), the computation is: + // filteredAvailableFunds = src-1360-a.totalAmount(200000) + unassigned.totalAmount(0) = 200000 + // totalRawProjected avg = (40000+60000)/2 = 50000 (WI only, no HI) + // adjustedTotalPayback = resolvedTotalPayback - resolvedTotalExcess + // totalMinPayback = 500 (wiTotals) + 0 (hiTotals) = 500 + // totalMaxPayback = 1000 + 0 = 1000 + // resolvedTotalPayback (avg) = (500+1000)/2 = 750 + // no subsidyAdjustments → excess = 0 + // adjustedTotalPayback = 750 + // Remaining Net = 200000 - 50000 + 750 = 150750 → '€150,750.00' + + it('Remaining Budget row uses filteredAvailableFunds (Scenario 23)', () => { const breakdown = buildServerFilteredBreakdown(); - // availableFunds = 200000 - // totalRawProjected avg = (40000+60000)/2 = 50000 (WI only, no HI) - // adjustedTotalPayback = resolvedTotalPayback - resolvedTotalExcess - // totalMinPayback = 500 (wiTotals) + 0 (hiTotals) = 500 - // totalMaxPayback = 1000 + 0 = 1000 - // resolvedTotalPayback (avg) = (500+1000)/2 = 750 - // no subsidyAdjustments → excess = 0 - // adjustedTotalPayback = 750 - // Remaining Net = 200000 - 50000 + 750 = 150750 → '€150,750.00' + // filteredAvailableFunds = 200000 (src-1360-a.totalAmount=200000; src-1360-b not in sources) renderWithRouter(breakdown, buildOverview(200000), { - deselectedSourceIds: new Set(['src-1360-b']), // hypothetical deselection + deselectedSourceIds: new Set(['src-1360-b']), // hypothetical deselection (src not in breakdown) }); const remainingRow = screen.getByRole('row', { name: /remaining budget/i }); From cfd202efa46f37fbcac6f05b497059f18d04abb8 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Mon, 27 Apr 2026 22:28:20 +0200 Subject: [PATCH 6/7] fix(budget): prefix unused overview param with _ to satisfy ESLint Co-Authored-By: Claude frontend-developer (Haiku 4.5) --- client/src/components/CostBreakdownTable/CostBreakdownTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx index 2315ca57..d63ccd4b 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx @@ -682,7 +682,7 @@ function ChevronSvg({ className }: { className: string }) { */ export function CostBreakdownTable({ breakdown, - overview, + overview: _overview, deselectedSourceIds, onSourceToggle, onSelectAllSources, From 3fc449f423c13c9f0a3a847124d4bbbdc631bc44 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Mon, 27 Apr 2026 23:03:36 +0200 Subject: [PATCH 7/7] test(budget): fix pre-existing CostBreakdownTable tests broken by filteredAvailableFunds After the production fix replacing overview.availableFunds with filteredAvailableFunds (computed from breakdown.budgetSources), 8 pre-existing tests using buildBreakdownWithWI() with empty budgetSources: [] produced filteredAvailableFunds=0, breaking expected Remaining Budget and Available Funds values. Added optional budgetSources parameter to buildBreakdownWithWI() and supplied matching totalAmount values in each failing test so filteredAvailableFunds equals the previously expected overview.availableFunds. Fixes #1366 Co-Authored-By: Claude qa-integration-tester (Sonnet) Co-Authored-By: Claude Sonnet 4.6 --- .../CostBreakdownTable/CostBreakdownTable.test.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx index 09702942..d11d4574 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx @@ -274,6 +274,7 @@ function buildBreakdownWithWI( workItemId?: string; description?: string | null; hasInvoice?: boolean; + budgetSources?: BudgetSourceSummaryBreakdown[]; // Legacy params — kept for backward compat but ignored (area is always No Area) categoryId?: string | null; categoryName?: string; @@ -358,7 +359,7 @@ function buildBreakdownWithWI( }, }, subsidyAdjustments: [], - budgetSources: [], + budgetSources: opts.budgetSources ?? [], }; } @@ -1093,7 +1094,7 @@ describe('CostBreakdownTable', () => { // availableFunds=100000, projectedMax=1200 → remaining = 98800 > 0 const { container } = render( {}} @@ -1893,6 +1894,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 5000, subsidyPayback: 1200, minSubsidyPayback: 800, + budgetSources: [buildSourceSummary({ totalAmount: 10000 })], })} overview={buildOverview(10000)} deselectedSourceIds={new Set()} @@ -1919,6 +1921,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 8000, subsidyPayback: 2000, minSubsidyPayback: 1000, + budgetSources: [buildSourceSummary({ totalAmount: 20000 })], })} overview={buildOverview(20000)} deselectedSourceIds={new Set()} @@ -1948,6 +1951,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 8000, subsidyPayback: 2000, minSubsidyPayback: 1000, + budgetSources: [buildSourceSummary({ totalAmount: 20000 })], })} overview={buildOverview(20000)} deselectedSourceIds={new Set()} @@ -1977,6 +1981,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 8000, subsidyPayback: 2000, minSubsidyPayback: 1000, + budgetSources: [buildSourceSummary({ totalAmount: 20000 })], })} overview={buildOverview(20000)} deselectedSourceIds={new Set()} @@ -2258,6 +2263,7 @@ describe('CostBreakdownTable', () => { projectedMax: 5000, rawProjectedMin: 3000, rawProjectedMax: 5000, + budgetSources: [buildSourceSummary({ totalAmount: 10000 })], })} overview={buildOverview(10000)} deselectedSourceIds={new Set()} @@ -2288,6 +2294,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 5000, subsidyPayback: 200, minSubsidyPayback: 100, + budgetSources: [buildSourceSummary({ totalAmount: 10000 })], })} overview={buildOverview(10000)} deselectedSourceIds={new Set()} @@ -2314,6 +2321,7 @@ describe('CostBreakdownTable', () => { projectedMax: 5000, rawProjectedMin: 3000, rawProjectedMax: 5000, + budgetSources: [buildSourceSummary({ totalAmount: 10000 })], })} overview={buildOverview(10000)} deselectedSourceIds={new Set()}