Skip to content
Merged
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
78 changes: 78 additions & 0 deletions frontend/src/__tests__/recommendations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <details> 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
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/purchase-compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const SP_SHORT_LABEL: Record<string, string> = {
// 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<string>([SAVINGS_PLANS_BUCKET_KEY, 'savingsplans']);
export const UMBRELLA_SLUGS: ReadonlySet<string> = new Set<string>([SAVINGS_PLANS_BUCKET_KEY, 'savingsplans']);

// savingsPlansBucketLabel formats the bulk-buy bucket title for one
// or more SP plan types. Returns:
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <summary> 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<string, LocalRecommendation[]>();
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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;
}

Expand Down
Loading