diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 8996d884..226dd234 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -70,6 +70,9 @@ jest.mock('../api', () => ({ // empty list so existing tests that don't care about the savings card // value don't need to be updated. getRecommendations: jest.fn().mockResolvedValue([]), + // #180: Home applies the Settings -> Enabled Providers filter, like the + // Opportunities page. Default to "no preference" (no filter). + getConfig: jest.fn().mockResolvedValue({ global: {} }), })); import { loadSavingsTrendChart, loadYtdSparkline, setupSavingsTrendHandlers, setupDashboardHandlers, formatTrendAxisTick } from '../dashboard'; import { formatTrendTooltipTitle } from '../modules/chart-utils'; @@ -801,6 +804,67 @@ describe('Dashboard Module', () => { expect(savingsCard?.textContent).not.toContain('$700'); }); + // #180: Home and Opportunities must sum the same rows, so Home applies the + // same Enabled Providers filter and counts the rows it summed. + describe('Enabled Providers scope parity with Opportunities (#180)', () => { + const scopedRecs = [ + { id: 'a1', provider: 'aws', service: 'ec2', region: 'us-east-1', resource_type: 't3.medium', term: 1, savings: 150, upfront_cost: 0, count: 1 }, + { id: 'a2', provider: 'aws', service: 'rds', region: 'us-east-1', resource_type: 'db.t3.medium', term: 1, savings: 100, upfront_cost: 0, count: 1 }, + { id: 'z1', provider: 'azure', service: 'aks', region: 'eastus', resource_type: 'Standard_D2_v3', term: 1, savings: 90, upfront_cost: 0, count: 1 }, + ]; + const summaryFor = (total: number) => ({ + potential_monthly_savings: 0, total_recommendations: total, active_commitments: 0, + committed_monthly: 0, current_coverage: 0, target_coverage: 80, ytd_savings: 0, by_service: {}, + }); + const savingsTile = (): Element | null => document.querySelector('[data-kpi="savings"]'); + const rangeInputIds = (): string[] => + (mockGroupRecsByCell.mock.calls[0]![0] as Array<{ id: string }>).map((r) => r.id); + + beforeEach(() => { + (api.getRecommendations as jest.Mock).mockResolvedValue(scopedRecs); + (api.getDashboardSummary as jest.Mock).mockResolvedValue(summaryFor(3)); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + }); + + test('drops recommendations for disabled providers before computing the range', async () => { + (api.getConfig as jest.Mock).mockResolvedValueOnce({ global: { enabled_providers: ['aws'] } }); + + await loadDashboard(); + + expect(rangeInputIds()).toEqual(['a1', 'a2']); + // The count matches the rows summed, not the backend total (3). + expect(savingsTile()?.querySelector('.kpi-tile-detail')?.textContent).toBe('2 recommendations'); + }); + + test('empty enabled_providers keeps every recommendation (permissive default)', async () => { + (api.getConfig as jest.Mock).mockResolvedValueOnce({ global: { enabled_providers: [] } }); + + await loadDashboard(); + + expect(rangeInputIds()).toEqual(['a1', 'a2', 'z1']); + expect(savingsTile()?.querySelector('.kpi-tile-detail')?.textContent).toBe('3 recommendations'); + }); + + test('a config fetch failure does not block the dashboard and applies no filter', async () => { + (api.getConfig as jest.Mock).mockRejectedValueOnce(new Error('403 Forbidden')); + + await loadDashboard(); + + expect(rangeInputIds()).toEqual(['a1', 'a2', 'z1']); + expect(savingsTile()?.querySelector('.kpi-tile-value')?.textContent).toBe('$300 – $400'); + }); + + test('falls back to the backend count when the recommendations fetch fails', async () => { + (api.getRecommendations as jest.Mock).mockRejectedValueOnce(new Error('boom')); + (api.getDashboardSummary as jest.Mock).mockResolvedValue(summaryFor(7)); + + await loadDashboard(); + + expect(savingsTile()?.querySelector('.kpi-tile-value')?.textContent).toBe('\u2014'); + expect(savingsTile()?.querySelector('.kpi-tile-detail')?.textContent).toBe('7 recommendations'); + }); + }); + // #978: Current Coverage KPI must render with exactly 1 decimal place so it // cannot overflow the tile box at narrower screen widths. test('#978: Current Coverage KPI rounds to 1 decimal place', async () => { diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index e6d1fdea..a5dbbc56 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -358,6 +358,11 @@ describe('Recommendations Module', () => { }; expect(cardValue()).toContain('$300'); expect(cardValue()).toContain('$400'); + // #180: unfiltered, the card covers every loaded row (same scope as + // Home), so it carries no "Filtered" scope line. + const scopeLine = (): string | null => + document.querySelector('#recommendations-summary [data-testid="savings-scope"]')?.textContent ?? null; + expect(scopeLine()).toBeNull(); // Apply a real column filter restricting to service=ec2 (cell1 // only). renderRecommendationsList re-runs applyColumnFilters @@ -375,6 +380,9 @@ describe('Recommendations Module', () => { expect(cardValue()).toContain('$150'); expect(cardValue()).not.toContain('$300'); expect(cardValue()).not.toContain('$400'); + // #180: the narrowed scope is labeled so it can't be mistaken for + // Home's all-recommendations figure. + expect(scopeLine()).toBe('Filtered: 2 of 4 recommendations'); // Restore the filter mock to empty so the leaked return value // doesn't bleed into subsequent tests in this file. jest.clearAllMocks @@ -383,6 +391,49 @@ describe('Recommendations Module', () => { (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({}); }); + test('savings card is labeled Filtered when the service filter is pushed to the server (#180)', async () => { + // A single-value service filter is sent as ?service= (#162) and the + // backend returns only matching rows, so the visible and loaded counts + // are equal. The card must still say it is filtered; otherwise it shows + // a smaller total than Home with no explanation. + const ec2Recs = [ + { id: 'cell1-cheap', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment_option: 'no-upfront', savings: 100, upfront_cost: 0 }, + { id: 'cell1-pricey', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, payment_option: 'all-upfront', savings: 150, upfront_cost: 1000 }, + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ + summary: { total_count: 2, total_monthly_savings: 250, total_upfront_cost: 1000, avg_payback_months: 1 }, + recommendations: ec2Recs, + regions: [], + }); + (state.getRecommendations as jest.Mock).mockReturnValue(ec2Recs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(ec2Recs); + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({ + service: { kind: 'set', values: ['ec2'] }, + }); + const scopeLine = (): string | null => + document.querySelector('#recommendations-summary [data-testid="savings-scope"]')?.textContent ?? null; + + try { + await loadRecommendations(); + + expect(api.getRecommendations).toHaveBeenLastCalledWith(expect.objectContaining({ service: 'ec2' })); + expect(scopeLine()).toBe('Filtered: 2 recommendations (Service: ec2)'); + + // A further client-side filter is counted, not named. + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({ + service: { kind: 'set', values: ['ec2'] }, + term: { kind: 'set', values: ['1'] }, + }); + document.querySelector('th[data-sort="service"]')?.click(); + expect(scopeLine()).toBe('Filtered: 2 recommendations (Service: ec2, +1 more filter)'); + } finally { + // Reload unfiltered so the pushed-down scope doesn't leak into later tests. + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({}); + await loadRecommendations(); + } + expect(scopeLine()).toBeNull(); + }); + test('summary cards narrow to selection in real time (closes #279)', async () => { // 4 recs / 2 cells. With no selection: cards reflect the full // visible set (savings $300–$400, upfront $0–$3000). After ticking diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 96999e1e..38f8aecd 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -16,6 +16,7 @@ import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefresh import { getRecommendationsFreshness } from './api/recommendations'; import { buildFirstRunEmptyState, isScopeFiltered } from './first-run-empty-state'; import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; +import { filterByEnabledProviders } from './lib/enabled-providers'; import { buildKpiTile } from './lib/kpi-tile'; import { scopeUpcomingToProvider, upcomingEmptyMessage, UPCOMING_ACCOUNT_SCOPE_NOTE } from './lib/upcoming-scope'; import { formatTrendAxisTick, formatTrendTooltipTitle, formatSparseTrendMessage, MIN_TREND_POINTS } from './modules/chart-utils'; @@ -151,10 +152,11 @@ export async function loadDashboard(): Promise { // move the range computation server-side if needed. // Promise.allSettled ensures the dashboard still renders if any individual // fetch fails -- each card falls back gracefully. - const [summaryResult, upcomingResult, recsResult, accountsResult] = await Promise.allSettled([ + const [summaryResult, upcomingResult, recsResult, enabledProviders, accountsResult] = await Promise.allSettled([ api.getDashboardSummary(currentProvider, currentAccountIDs), api.getUpcomingPurchases(), api.getRecommendations({ provider: currentProvider, account_ids: currentAccountIDs }), + loadEnabledProviders(), // Only feeds the first-run onboarding card (issue #756); wrapped so a // missing or failing endpoint degrades to "unknown" instead of // breaking the dashboard. @@ -181,7 +183,15 @@ export async function loadDashboard(): Promise { : (rawRecs != null && typeof rawRecs === 'object' && Array.isArray((rawRecs as { recommendations?: unknown }).recommendations)) ? (rawRecs as { recommendations: unknown[] }).recommendations : []; - const recs: readonly LocalRecommendation[] = recsArray as unknown as LocalRecommendation[]; + // #180: apply the same Settings -> Enabled Providers filter as the + // Opportunities page, so the two Potential Monthly Savings cards are + // computed from the same rows. A failed recs fetch stays distinguishable + // (null) so the tile can fall back to the backend count. + const recs: readonly LocalRecommendation[] = filterByEnabledProviders( + recsArray as unknown as LocalRecommendation[], + enabledProviders.status === 'fulfilled' ? enabledProviders.value : null, + ); + const recsCount = recsResult.status === 'fulfilled' ? recs.length : null; if (summaryResult.status === 'rejected') { throw summaryResult.reason as Error; @@ -194,7 +204,10 @@ export async function loadDashboard(): Promise { // on the Home chart. Mirrors the pattern from loadSavingsTrendChart (#747). const filterDesc = buildFilterDesc(currentProvider, currentAccountIDs); - renderDashboardSummary(summaryData, recs); + renderDashboardSummary(summaryData, recs, recsCount); + // `recs` is already narrowed to Enabled Providers (#180), matching the + // Opportunities first-run check, which also runs on the preference- + // filtered list. renderHomeOnboarding( recsResult.status === 'fulfilled' && recs.length === 0 && !summaryData.active_commitments, accountsResult.status === 'fulfilled' && Array.isArray(accountsResult.value) @@ -244,6 +257,20 @@ export async function loadDashboard(): Promise { } } +/** + * Settings -> Enabled Providers, or null when the config can't be read (a + * missing permission or network error must not block Home; null means "no + * filter", the same permissive fallback the Opportunities page uses). + */ +async function loadEnabledProviders(): Promise { + try { + const cfg = await api.getConfig(); + return cfg?.global?.enabled_providers ?? null; + } catch { + return null; + } +} + /** * Issue #756: first-run onboarding card at the top of Home. Shown when no * recommendations loaded and nothing is committed yet, so a new user gets a @@ -266,7 +293,11 @@ function renderHomeOnboarding(show: boolean, hasAccounts: boolean | null, scopeF })); } -function renderDashboardSummary(data: DashboardSummary, recs: readonly LocalRecommendation[]): void { +function renderDashboardSummary( + data: DashboardSummary, + recs: readonly LocalRecommendation[], + recsCount: number | null, +): void { const summary = document.getElementById('summary'); if (!summary) return; @@ -323,7 +354,9 @@ function renderDashboardSummary(data: DashboardSummary, recs: readonly LocalReco detail: string; }> = [ { kpi: 'savings', title: 'Potential Monthly Savings', value: savingsDisplay, valueSavings: true, - detail: `${data.total_recommendations ?? NO_VALUE} recommendations` }, + // #180: count the same rows the range sums (and the Opportunities page + // counts); the backend total ignores the Enabled Providers setting. + detail: `${recsCount ?? data.total_recommendations ?? NO_VALUE} recommendations` }, { kpi: 'commitments', title: 'Active Commitments', value: data.active_commitments != null ? String(data.active_commitments) : NO_VALUE, detail: `${formatCurrency(data.committed_monthly)}/mo committed` }, { kpi: 'coverage', title: 'Current Coverage', value: coverageValue, detail: coverageDetail }, diff --git a/frontend/src/lib/enabled-providers.ts b/frontend/src/lib/enabled-providers.ts new file mode 100644 index 00000000..e34e939b --- /dev/null +++ b/frontend/src/lib/enabled-providers.ts @@ -0,0 +1,26 @@ +/** + * Settings -> General -> Enabled Providers scoping for recommendation lists + * (issues #463 and #180). + * + * The backend returns every collected recommendation irrespective of the + * `enabled_providers` preference, so each screen that sums recommendations + * must apply the same client-side filter. Home and Opportunities both use + * this helper so their "Potential Monthly Savings" figures are computed + * from the same set of rows and cannot drift apart. + * + * Permissive default: an empty or absent list (older configs, or a tenant + * that never touched the toggles) means "no filter", matching the Settings + * load path, rather than hiding every recommendation. + */ + +/** + * Keep only the recommendations whose provider is enabled. Returns a copy of + * the input when `enabledProviders` is empty, null or undefined. + */ +export function filterByEnabledProviders( + recs: readonly T[], + enabledProviders: readonly string[] | null | undefined, +): T[] { + if (!enabledProviders || enabledProviders.length === 0) return [...recs]; + return recs.filter((r) => enabledProviders.includes(r.provider)); +} diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 5985f838..4d6328ac 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -6,6 +6,7 @@ import * as api from './api'; import * as state from './state'; import type { CostPeriod } from './state'; import { formatCurrency, formatTerm, escapeHtml, CURRENCY_DEFAULT_DIGITS, NO_VALUE } from './utils'; +import { filterByEnabledProviders } from './lib/enabled-providers'; import { getRecommendationsFreshness, refreshRecommendations as refreshRecommendationsAPI } from './api/recommendations'; import type { RecommendationsFreshness } from './api/recommendations'; import { showToast } from './toast'; @@ -113,6 +114,12 @@ const expandedSpGroups = new Set(); // in sync with the per-cell banner range under the table). let lastRecommendationsSummary: RecommendationsSummary | null = null; +// #180: the single-value column filters the last successful fetch sent to the +// server (#162 pushdown). The loaded list is already narrowed by these, so the +// unfiltered total is unknown and the savings card must still say "Filtered" +// even though the visible and loaded counts match. +let loadedServerFilters: ReadonlyArray<{ column: state.RecommendationsColumnId; value: string }> = []; + // #284 (CR follow-up): guard against concurrent stale-load refreshes. If a // background refresh is already in flight, any subsequent stale detection // skips kicking off a second request (and duplicate toasts). @@ -653,12 +660,15 @@ export async function loadRecommendations(): Promise { // permissive default on the checkboxes (line ~2510). const enabledProviders = cfgResponse?.global?.enabled_providers; const rawRecs = (data.recommendations || []) as unknown as api.Recommendation[]; - const visibleByPreference: api.Recommendation[] = enabledProviders && enabledProviders.length > 0 - ? rawRecs.filter(r => enabledProviders.includes(r.provider as api.Provider)) - : rawRecs; + // Shared with Home's savings card (#180) so both screens sum the same rows. + const visibleByPreference = filterByEnabledProviders(rawRecs, enabledProviders); state.setRecommendations(visibleByPreference); state.clearSelectedRecommendations(); + loadedServerFilters = [ + ...(pushedService !== undefined ? [{ column: 'service' as const, value: pushedService }] : []), + ...(pushedRegion !== undefined ? [{ column: 'region' as const, value: pushedRegion }] : []), + ]; // Cache the API-derived summary so renderRecommendationsList can // recompute the savings card from the visible (post-filter) set on @@ -850,9 +860,31 @@ async function onLookbackChange(rawValue: string): Promise { } } +/** + * #180: the savings card's scope line, or null when the card covers every + * recommendation (the same scope as Home). "Filtered" is decided by whether a + * column filter is active or the fetch itself was narrowed server-side, not by + * comparing counts: a pushed-down service/region filter returns only matching + * rows, so the visible and loaded counts are equal. In that case the + * unfiltered total isn't known, so the line names the server-side filter + * instead of an "of Y" figure. + */ +function savingsScopeText(visibleCount: number, loadedCount: number): string | null { + const active = Object.keys(state.getRecommendationsColumnFilters()); + if (loadedServerFilters.length > 0) { + const pushed = loadedServerFilters.map((f) => `${columnLabel(f.column)}: ${f.value}`); + const others = active.filter((col) => !loadedServerFilters.some((f) => f.column === col)).length; + if (others > 0) pushed.push(`+${others} more ${others === 1 ? 'filter' : 'filters'}`); + return `Filtered: ${visibleCount} recommendations (${pushed.join(', ')})`; + } + if (active.length === 0 && visibleCount >= loadedCount) return null; + return `Filtered: ${visibleCount} of ${loadedCount} recommendations`; +} + function renderRecommendationsSummary( _summary: RecommendationsSummary | null, recommendations: readonly LocalRecommendation[], + loadedCount: number = recommendations.length, ): void { const container = document.getElementById('recommendations-summary'); if (!container) return; @@ -922,17 +954,26 @@ function renderRecommendationsSummary( const sfx = periodSuffix(period); return isSelectionView ? `Selected Savings ${sfx}` : `Potential Savings ${sfx}`; })(); + // #180: Home's Potential Monthly Savings always covers every + // recommendation, so when column filters narrow this page the card must say + // so; otherwise the two same-named KPIs disagree with no visible reason. + const scopeText = isSelectionView ? null : savingsScopeText(variantCount, loadedCount); const upfrontLabel = isSelectionView ? 'Selected Upfront Cost' : 'Total Upfront Cost'; const paybackLabel = 'Payback Period'; // Issue #743: the same .kpi-tile component as Home's summary, so the two - // pages' KPI cards match. + // pages' KPI cards match. The #180 scope line is the savings tile's detail. renderKpiTiles(container, [ { kpi: 'count', title: countLabel, value: String(variantCount) }, - { kpi: 'savings', title: savingsLabel, value: savingsText, valueSavings: true }, + { + kpi: 'savings', title: savingsLabel, value: savingsText, valueSavings: true, + ...(scopeText ? { detail: scopeText } : {}), + }, { kpi: 'upfront', title: upfrontLabel, value: upfrontText }, { kpi: 'payback', title: paybackLabel, value: paybackText }, ]); + const scopeDetail = container.querySelector('[data-kpi="savings"] .kpi-tile-detail'); + if (scopeDetail) scopeDetail.dataset['testid'] = 'savings-scope'; } // Comparator extractors per column. Numeric columns return numbers @@ -4913,7 +4954,7 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { // total_upfront_cost, avg_payback_months); the savings figure itself is // recomputed from `recommendations` inside renderRecommendationsSummary // via the same pageLevelRange the banner uses. - renderRecommendationsSummary(lastRecommendationsSummary, recommendations); + renderRecommendationsSummary(lastRecommendationsSummary, recommendations, loadedRecs?.length ?? 0); // Mount once; update is per-render below. mountBottomActionBox(); diff --git a/frontend/tests-e2e/fixtures/recs.ts b/frontend/tests-e2e/fixtures/recs.ts index 47b31284..e7626d6a 100644 --- a/frontend/tests-e2e/fixtures/recs.ts +++ b/frontend/tests-e2e/fixtures/recs.ts @@ -293,6 +293,11 @@ export async function mockApi(page: Page): Promise { if (provider && provider !== 'all' && provider !== '') { rows = RECS.filter((r) => r.provider === provider); } + // Like the backend, apply the single-value service/region pushdown (#162). + const service = u.searchParams.get('service'); + if (service) rows = rows.filter((r) => r.service === service); + const region = u.searchParams.get('region'); + if (region) rows = rows.filter((r) => r.region === region); if (pendingDelayMs > 0) { const wait = pendingDelayMs; pendingDelayMs = 0; diff --git a/frontend/tests-e2e/savings-kpi-parity.spec.ts b/frontend/tests-e2e/savings-kpi-parity.spec.ts new file mode 100644 index 00000000..bade659a --- /dev/null +++ b/frontend/tests-e2e/savings-kpi-parity.spec.ts @@ -0,0 +1,93 @@ +/** + * Issue #180: Home and Opportunities both show "Potential Monthly Savings" + * for the same recommendations, so the two figures must agree. Opportunities + * honors Settings -> Enabled Providers (#463); Home used to sum every + * provider, so a tenant with a provider switched off saw two different + * numbers. When the Opportunities card is narrowed by column filters, the + * scope difference must be labeled on the card itself. + */ +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth, RECS } from './fixtures/recs'; + +// Every fixture row is its own cell with a single variant, so each page-level +// range collapses to the plain sum of the enabled rows. +const AWS_TOTAL = RECS.filter((r) => r.provider === 'aws').reduce((s, r) => s + r.savings, 0); +const AWS_COUNT = RECS.filter((r) => r.provider === 'aws').length; +const AWS_EC2_COUNT = RECS.filter((r) => r.provider === 'aws' && r.service === 'ec2').length; +const usd = (n: number): string => `$${n.toLocaleString('en-US')}`; + +async function setup(page: Page): Promise { + await seedAuth(page); + await mockApi(page); + // Azure and GCP are switched off in Settings. + await page.route('**/api/config', (route) => + route.fulfill({ json: { global: { enabled_providers: ['aws'] }, services: {} } }), + ); + await page.route('**/api/dashboard/upcoming', (route) => route.fulfill({ json: { purchases: [] } })); + await page.route('**/api/dashboard/summary**', (route) => + route.fulfill({ + json: { + potential_monthly_savings: 0, total_recommendations: RECS.length, active_commitments: 0, + committed_monthly: 0, current_coverage: 0, target_coverage: 80, ytd_savings: 0, by_service: {}, + }, + }), + ); +} + +function opportunitiesSavingsCard(page: Page) { + return page.locator('#recommendations-summary .card').filter({ hasText: 'Potential Monthly Savings' }); +} + +test('Home and Opportunities agree on Potential Monthly Savings when a provider is disabled', async ({ page }) => { + await setup(page); + + await page.goto('/home'); + const homeTile = page.locator('#summary [data-kpi="savings"]'); + await expect(homeTile.locator('.kpi-tile-value')).toHaveText(usd(AWS_TOTAL)); + await expect(homeTile.locator('.kpi-tile-detail')).toHaveText(`${AWS_COUNT} recommendations`); + + await page.goto('/opportunities'); + const card = opportunitiesSavingsCard(page); + await expect(card.locator('.value.savings')).toHaveText(usd(AWS_TOTAL)); + // Unfiltered: same scope as Home, so no scope label. + await expect(card.getByTestId('savings-scope')).toHaveCount(0); +}); + +test('a column-filtered Opportunities card labels its narrower scope', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('cudly.recs.columnFilters.v1', JSON.stringify({ + schemaVersion: 1, + filters: { service: { kind: 'set', values: ['ec2'] } }, + })); + }); + await setup(page); + + await page.goto('/opportunities'); + await expect(opportunitiesSavingsCard(page).getByTestId('savings-scope')) + .toHaveText(`Filtered: ${AWS_EC2_COUNT} of ${AWS_COUNT} recommendations`); +}); + +test('the card stays labeled when the service filter is sent to the server', async ({ page }) => { + // The persisted filter is seeded after the first fetch, so only a later + // load (here: leaving and returning to Opportunities) sends ?service=ec2. + // The server then returns only ec2 rows and the counts match, but the card + // still covers less than Home's figure. + await page.addInitScript(() => { + localStorage.setItem('cudly.recs.columnFilters.v1', JSON.stringify({ + schemaVersion: 1, + filters: { service: { kind: 'set', values: ['ec2'] } }, + })); + }); + await setup(page); + + await page.goto('/opportunities'); + const scope = opportunitiesSavingsCard(page).getByTestId('savings-scope'); + await expect(scope).toHaveText(`Filtered: ${AWS_EC2_COUNT} of ${AWS_COUNT} recommendations`); + + await page.locator('#home-tab-btn').click(); + const pushed = page.waitForRequest((req) => + req.url().includes('/api/recommendations') && new URL(req.url()).searchParams.get('service') === 'ec2'); + await page.locator('#opportunities-tab-btn').click(); + await pushed; + await expect(scope).toHaveText(`Filtered: ${AWS_EC2_COUNT} recommendations (Service: ec2)`); +});