diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index 30bf8668d..f922c22e5 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -3447,6 +3447,84 @@ describe('Issue #132: bulk-buy collapses SP plan types into one bucket', () => { // Non-SP bucket title still uses the raw service slug. expect(sectionTitles.some((t) => t.includes('AWS / ec2'))).toBe(true); }); + + // Issue #249: mixed-SP bucket renders collapsible per-plan-type sub-rows + // inside the fan-out section so the operator can see how the bulk-buy is + // split across plan types before submitting. + test('mixed-SP fan-out section renders per-plan-type breakdown (closes #249)', async () => { + const recs = [ + { id: 's1', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-compute', resource_type: 'sp', region: 'us-east-1', count: 2, term: 1, savings: 100, upfront_cost: 500 }, + { id: 's2', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-sagemaker', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, savings: 200, upfront_cost: 800 }, + { id: 'e1', provider: 'aws', cloud_account_id: 'a1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', count: 1, term: 1, savings: 50, upfront_cost: 300 }, + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(recs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(recs); + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({}); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(recs.map((r) => r.id as string))); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + + // The SP bucket section must contain a
element with the + // plan-type breakdown (issue #249). + const spSection = Array.from(document.querySelectorAll('.fanout-bucket')).find( + (s) => s.querySelector('h4')?.textContent?.includes('Savings Plans'), + ); + expect(spSection).toBeDefined(); + + // The collapsible group is present with "+2 plan types" summary. + const details = spSection!.querySelector('details.fanout-sp-plan-types'); + expect(details).not.toBeNull(); + const summaryText = details!.querySelector('summary')?.textContent ?? ''; + expect(summaryText).toBe('+2 plan types'); + + // Each plan type gets its own row labelled with the short plan-type name. + const planRows = Array.from(details!.querySelectorAll('p.fanout-sp-plan-type-row')) + .map((el) => el.textContent ?? ''); + expect(planRows).toHaveLength(2); + expect(planRows.some((t) => t.startsWith('Savings Plans (Compute)'))).toBe(true); + expect(planRows.some((t) => t.startsWith('Savings Plans (SageMaker)'))).toBe(true); + + // EC2 (non-SP) bucket must NOT render the plan-type breakdown. + const ec2Section = Array.from(document.querySelectorAll('.fanout-bucket')).find( + (s) => s.querySelector('h4')?.textContent?.includes('ec2'), + ); + expect(ec2Section).toBeDefined(); + expect(ec2Section!.querySelector('details.fanout-sp-plan-types')).toBeNull(); + }); + + // Issue #249 regression: umbrella slugs ("savings-plans", "savingsplans") + // must be excluded from byPlanType so they do not inflate the "+N plan + // types" count or render a spurious non-concrete plan-type row. + test('umbrella SP slugs excluded from plan-type fan-out count (issue #249)', async () => { + const recs = [ + { id: 's1', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans-compute', resource_type: 'sp', region: 'us-east-1', count: 2, term: 1, savings: 100, upfront_cost: 500 }, + { id: 's2', provider: 'aws', cloud_account_id: 'a1', service: 'savings-plans', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, savings: 200, upfront_cost: 800 }, + { id: 's3', provider: 'azure', cloud_account_id: 'a1', service: 'savingsplans', resource_type: 'sp', region: 'us-east-1', count: 1, term: 1, savings: 50, upfront_cost: 300 }, + ]; + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] }); + (state.getRecommendations as jest.Mock).mockReturnValue(recs); + (state.getVisibleRecommendations as jest.Mock).mockReturnValue(recs); + (state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({}); + (state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(recs.map((r) => r.id as string))); + + await loadRecommendations(); + (document.getElementById('bulk-purchase-btn') as HTMLButtonElement).click(); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + + const spSections = Array.from(document.querySelectorAll('.fanout-bucket')).filter( + (s) => s.querySelector('h4')?.textContent?.includes('Savings Plans'), + ); + expect(spSections.length).toBeGreaterThan(0); + + // Only 1 concrete plan type (Compute) after filtering umbrella slugs; + // the collapsible block must NOT be rendered (size < 2) for any SP bucket. + for (const section of spSections) { + expect(section.querySelector('details.fanout-sp-plan-types')).toBeNull(); + } + }); }); // Issue #658: Azure SP rows (service="savingsplans") must collapse into diff --git a/frontend/src/lib/purchase-compatibility.ts b/frontend/src/lib/purchase-compatibility.ts index 005ea1b7b..f6ac892a0 100644 --- a/frontend/src/lib/purchase-compatibility.ts +++ b/frontend/src/lib/purchase-compatibility.ts @@ -109,7 +109,7 @@ const SP_SHORT_LABEL: Record = { // slug and the legacy AWS SP umbrella (common.ServiceSavingsPlans). It is // a family marker, not a plan-type label, so it is treated the same way // as SAVINGS_PLANS_BUCKET_KEY. -const UMBRELLA_SLUGS = new Set([SAVINGS_PLANS_BUCKET_KEY, 'savingsplans']); +export const UMBRELLA_SLUGS: ReadonlySet = new Set([SAVINGS_PLANS_BUCKET_KEY, 'savingsplans']); // savingsPlansBucketLabel formats the bulk-buy bucket title for one // or more SP plan types. Returns: diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index b0ab6f8ce..18bafdea7 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -14,6 +14,7 @@ import { paymentOptionsFor, SAVINGS_PLANS_BUCKET_KEY, savingsPlansBucketLabel, + UMBRELLA_SLUGS, type Payment as CompatPayment, type Provider as CompatProvider, } from './lib/purchase-compatibility'; @@ -3549,6 +3550,55 @@ function renderFanOutBucketSection(b: FanOutBucket): HTMLElement { totals.textContent = `${bucketTotal.count} commitments · ${formatCurrency(bucketTotal.upfront)} upfront · ${formatCostForPeriod(bucketTotal.savings, bPeriod)} savings ${periodSuffix(bPeriod)}`; section.appendChild(totals); + // Issue #249: for mixed-SP buckets with 2+ distinct plan types, render + // a collapsible per-plan-type breakdown so operators can see how their + // bulk-buy is split across Compute / SageMaker / EC2 Instance / Database + // plan types before submitting. Collapsed by default to keep the modal + // compact; the chevron in the affords expand. + if (isSPBucket) { + // Group recs by their per-rec service slug, excluding umbrella slugs + // (e.g. "savings-plans", "savingsplans") that represent the SP family + // as a whole rather than a specific plan type. Including them would + // inflate the "+N plan types" count and render a spurious non-concrete + // plan-type row. Issue #249. + const byPlanType = new Map(); + for (const r of b.recs) { + if (UMBRELLA_SLUGS.has(r.service)) continue; + const existing = byPlanType.get(r.service); + if (existing) { + existing.push(r); + } else { + byPlanType.set(r.service, [r]); + } + } + if (byPlanType.size >= 2) { + const details = document.createElement('details'); + details.className = 'fanout-sp-plan-types'; + const summaryEl = document.createElement('summary'); + summaryEl.className = 'fanout-sp-plan-types-summary'; + summaryEl.textContent = `+${byPlanType.size} plan types`; + details.appendChild(summaryEl); + + for (const [slug, planRecs] of byPlanType) { + const planLabel = savingsPlansBucketLabel([slug]); + const planTotal = planRecs.reduce( + (acc, r) => ({ + count: acc.count + r.count, + upfront: acc.upfront + r.upfront_cost, + savings: acc.savings + r.savings, + }), + { count: 0, upfront: 0, savings: 0 }, + ); + const row = document.createElement('p'); + row.className = 'fanout-sp-plan-type-row'; + row.textContent = `${planLabel}: ${planTotal.count} commitment${planTotal.count === 1 ? '' : 's'} · ${formatCurrency(planTotal.upfront)} upfront · ${formatCostForPeriod(planTotal.savings, bPeriod)} savings ${periodSuffix(bPeriod)}`; + details.appendChild(row); + } + + section.appendChild(details); + } + } + return section; }