Skip to content

Commit 12c54d9

Browse files
committed
fix(frontend/recs): enforce one-variant-per-cell radio selection (closes #224)
After PR #195 (issue #188) the recommendations refresh fans out across 2 terms × 3 payments per `(provider, account, service, region, resource_type, engine)` cell — up to 6 alternative rec rows per physical resource. They're alternatives, not additions, but the checkboxes had no mutual-exclusion logic, producing wrong purchase intent in two ways: 1. **Manual checking**: a user could check `1yr/all-upfront` AND `3yr/no-upfront` for the same EC2 m5.large in the same account. Both fed into the purchase plan → double commitment for one resource. 2. **`select-all` was the destructive form**: every visible row got added, so 3 cells × 6 variants = 18 commitments instead of the 3 the user expected when clicking "buy what's recommended". 6× the intended spend per cell. # What changes - New `cellKey(rec)` helper returns the `(provider, cloud_account_id, service, region, resource_type, engine)` prefix (same as the scheduler ID encoding from #189, minus the `(term, payment)` suffix). Recs sharing this key are alternatives for the same physical resource. - Per-row checkbox change handler now enforces radio behaviour: on check, scan the visible-recs list for any sibling in the same cell that's already selected, deselect it FIRST, then add the new rec. Cells are independent — selecting in cell X doesn't touch cell Y's selection. - `select-all` handler rewritten: clear current selection, then call new `pickBestVariantPerCell(recs)` which groups by cell and picks the variant with the highest **effective monthly savings**: `effective = savings - (upfront_cost / (term * 12))`. Amortizing the upfront over the commitment term means a 3yr/all-upfront with a huge lump-sum doesn't beat a 1yr/no-upfront just on raw `savings`. Sibling issue #223 will replace this tiebreaker with "matches resolved GlobalConfig.DefaultTerm + DefaultPayment" when it lands; until then, amortized savings is the right deterministic default. # Out of scope (deliberate) - **Native `<input type="radio" name="cell-X">` markup**. Per the issue: "Designer call — if cell-grouping (sibling issue #226) lands first, radios become visually correct." Stayed with checkboxes-with-radio-behaviour for this PR; markup switch waits for cell-grouping visual. - **Default-select per cell from settings** (sibling #223). # Tests 4 new tests in `frontend/src/__tests__/recommendations.test.ts` inside the `'Issue #224: one-variant-per-cell radio selection'` describe block: (a) Manual toggle within a cell — checking variant B with variant A already selected: A is removed, B is added. Sibling A was not also added; B was not also removed. (b) Cross-cell independence — selecting in cell X must NOT remove cell Y's existing selection. (c) `select-all` collapses 18 → 3: 3 cells × 6 variants. After click, exactly 3 add calls; clearSelectedRecommendations was called first to drop stale state. (d) Tiebreaker pin — single cell with 3 variants whose `(savings, upfront_cost, term)` produce known amortized values ($200, $300, $400). The middle variant wins ($400 effective) despite the high-upfront variant having $1200 raw savings, proving the amortization is actually computed. `tsc --noEmit` clean. `npx jest --testPathPatterns="recommendations"` exit 0. `npm run build` (webpack production) exit 0.
1 parent c84fd02 commit 12c54d9

2 files changed

Lines changed: 236 additions & 1 deletion

File tree

frontend/src/__tests__/recommendations.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2023,3 +2023,165 @@ describe('Issue #132: bulk-buy collapses SP plan types into one bucket', () => {
20232023
expect(sectionTitles.some((t) => t.includes('AWS / ec2'))).toBe(true);
20242024
});
20252025
});
2026+
2027+
// Issue #224: at most one (term, payment) variant per physical-resource
2028+
// cell can be selected at any time. After PR #195's per-cell fan-out (2
2029+
// terms × 3 payments per cell), naive selection produces wrong purchase
2030+
// intent — manual checking lets the user accumulate sibling commitments,
2031+
// and `select-all` over-commits 6×. Cell = `(provider, account, service,
2032+
// region, resource_type, engine)`. The fix lives in two places: the
2033+
// per-row checkbox change handler (deselect any in-cell sibling on check)
2034+
// and the select-all handler (group by cell, pick highest-effective per).
2035+
describe('Issue #224: one-variant-per-cell radio selection', () => {
2036+
beforeEach(() => {
2037+
document.body.replaceChildren();
2038+
const recsTab = document.createElement('div');
2039+
recsTab.id = 'recommendations-tab';
2040+
recsTab.className = 'tab-content active';
2041+
const summary = document.createElement('div');
2042+
summary.id = 'recommendations-summary';
2043+
const list = document.createElement('div');
2044+
list.id = 'recommendations-list';
2045+
recsTab.appendChild(summary);
2046+
recsTab.appendChild(list);
2047+
document.body.appendChild(recsTab);
2048+
const purchaseModal = document.createElement('div');
2049+
purchaseModal.id = 'purchase-modal';
2050+
purchaseModal.className = 'hidden';
2051+
document.body.appendChild(purchaseModal);
2052+
jest.clearAllMocks();
2053+
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({});
2054+
(state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set());
2055+
});
2056+
2057+
// (a) Manual toggle: two variants of the same cell. Checking variant B
2058+
// when variant A is already selected must remove A first, leaving only B.
2059+
test('(a) checking variant B in same cell deselects sibling variant A', async () => {
2060+
const recs = [
2061+
{ id: 'cellA-1y-allup', provider: 'aws', cloud_account_id: 'acct-1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', engine: '', count: 1, term: 1, savings: 100, upfront_cost: 500 },
2062+
{ id: 'cellA-3y-noup', provider: 'aws', cloud_account_id: 'acct-1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', engine: '', count: 1, term: 3, savings: 200, upfront_cost: 0 },
2063+
];
2064+
(api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] });
2065+
// Pretend variant A is already selected (the "user previously checked it" state).
2066+
(state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['cellA-1y-allup']));
2067+
await loadRecommendations();
2068+
2069+
// Tick variant B in the same cell.
2070+
const cbs = Array.from(document.querySelectorAll<HTMLInputElement>('tbody input[data-rec-id]'));
2071+
const variantB = cbs.find((cb) => cb.dataset['recId'] === 'cellA-3y-noup');
2072+
expect(variantB).toBeDefined();
2073+
variantB!.checked = true;
2074+
variantB!.dispatchEvent(new Event('change'));
2075+
2076+
// The handler must have removed sibling A AND added B.
2077+
const removed = (state.removeSelectedRecommendation as jest.Mock).mock.calls.map((c) => c[0]);
2078+
const added = (state.addSelectedRecommendation as jest.Mock).mock.calls.map((c) => c[0]);
2079+
expect(removed).toContain('cellA-1y-allup');
2080+
expect(added).toContain('cellA-3y-noup');
2081+
// Sanity: B was not also removed, A was not also added.
2082+
expect(removed).not.toContain('cellA-3y-noup');
2083+
expect(added).not.toContain('cellA-1y-allup');
2084+
});
2085+
2086+
// (b) Cross-cell independence: selecting a rec in cell X must not
2087+
// affect cell Y's selection state. The radio enforcement is per-cell,
2088+
// not global.
2089+
test('(b) selecting in cell X does not affect cell Y selections', async () => {
2090+
const recs = [
2091+
{ id: 'cellX-1y', provider: 'aws', cloud_account_id: 'acct-1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', engine: '', count: 1, term: 1, savings: 100, upfront_cost: 500 },
2092+
{ id: 'cellY-1y', provider: 'aws', cloud_account_id: 'acct-1', service: 'rds', resource_type: 'db.r5.large', region: 'us-east-1', engine: 'mysql', count: 1, term: 1, savings: 200, upfront_cost: 1000 },
2093+
];
2094+
(api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] });
2095+
// Pretend cellY is already selected.
2096+
(state.getSelectedRecommendationIDs as jest.Mock).mockReturnValue(new Set(['cellY-1y']));
2097+
await loadRecommendations();
2098+
2099+
// Tick cellX.
2100+
const cbs = Array.from(document.querySelectorAll<HTMLInputElement>('tbody input[data-rec-id]'));
2101+
const cellX = cbs.find((cb) => cb.dataset['recId'] === 'cellX-1y');
2102+
expect(cellX).toBeDefined();
2103+
cellX!.checked = true;
2104+
cellX!.dispatchEvent(new Event('change'));
2105+
2106+
// cellY must NOT have been removed — cells are independent.
2107+
const removed = (state.removeSelectedRecommendation as jest.Mock).mock.calls.map((c) => c[0]);
2108+
expect(removed).not.toContain('cellY-1y');
2109+
const added = (state.addSelectedRecommendation as jest.Mock).mock.calls.map((c) => c[0]);
2110+
expect(added).toContain('cellX-1y');
2111+
});
2112+
2113+
// (c) Select-all collapses to one-per-cell. Three distinct cells × six
2114+
// variants each = 18 recs. After select-all, exactly 3 should be added,
2115+
// not 18 (one per cell). This is the headline money-impact fix.
2116+
test('(c) select-all picks exactly one variant per cell (3 cells × 6 variants → 3 selected)', async () => {
2117+
const cells = [
2118+
{ account: 'acct-1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', engine: '' },
2119+
{ account: 'acct-1', service: 'ec2', resource_type: 'm5.large', region: 'us-east-1', engine: '' },
2120+
{ account: 'acct-1', service: 'rds', resource_type: 'db.r5.large', region: 'eu-west-1', engine: 'mysql' },
2121+
];
2122+
const variants = [
2123+
{ term: 1, payment: 'all-upfront' },
2124+
{ term: 1, payment: 'partial-upfront' },
2125+
{ term: 1, payment: 'no-upfront' },
2126+
{ term: 3, payment: 'all-upfront' },
2127+
{ term: 3, payment: 'partial-upfront' },
2128+
{ term: 3, payment: 'no-upfront' },
2129+
];
2130+
const recs: Array<Record<string, unknown>> = [];
2131+
let i = 0;
2132+
for (const c of cells) {
2133+
for (const v of variants) {
2134+
recs.push({
2135+
id: `c${i++}`,
2136+
provider: 'aws', cloud_account_id: c.account, service: c.service,
2137+
resource_type: c.resource_type, region: c.region, engine: c.engine,
2138+
count: 1, term: v.term, savings: 100 + i, upfront_cost: 500,
2139+
});
2140+
}
2141+
}
2142+
expect(recs).toHaveLength(18);
2143+
2144+
(api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] });
2145+
await loadRecommendations();
2146+
2147+
const selectAll = document.getElementById('select-all-recs') as HTMLInputElement;
2148+
expect(selectAll).not.toBeNull();
2149+
selectAll.checked = true;
2150+
selectAll.dispatchEvent(new Event('change'));
2151+
2152+
// Exactly 3 add calls — one per cell.
2153+
expect((state.addSelectedRecommendation as jest.Mock).mock.calls).toHaveLength(3);
2154+
// And clearSelectedRecommendations was called first to drop any stale state.
2155+
expect(state.clearSelectedRecommendations).toHaveBeenCalled();
2156+
});
2157+
2158+
// (d) Tiebreaker: when multiple variants share a cell, select-all picks
2159+
// the variant with the highest EFFECTIVE monthly savings (amortizing
2160+
// upfront over term * 12 months) — NOT the highest raw `savings`.
2161+
// Concrete example: a 3yr/all-upfront with $36000 upfront + $1200/mo
2162+
// headline savings has effective = 1200 - 36000/36 = $200. A 1yr/no-upfront
2163+
// with $0 upfront + $300/mo headline savings has effective = $300. The
2164+
// 1yr/no-upfront wins despite the 3yr's higher raw `savings`.
2165+
test('(d) select-all picks highest-effective-savings (amortized) per cell', async () => {
2166+
const recs = [
2167+
// 3yr/all-upfront — high raw savings ($1200/mo) but huge upfront drags effective to $200/mo.
2168+
{ id: 'big-upfront', provider: 'aws', cloud_account_id: 'acct-1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', engine: '', count: 1, term: 3, savings: 1200, upfront_cost: 36000 },
2169+
// 1yr/no-upfront — lower raw ($300/mo) but $0 upfront → effective stays at $300/mo.
2170+
{ id: 'no-upfront', provider: 'aws', cloud_account_id: 'acct-1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', engine: '', count: 1, term: 1, savings: 300, upfront_cost: 0 },
2171+
// 3yr/partial-upfront — middle of the road ($600/mo savings, $7200 upfront → effective = 600 - 7200/36 = $400).
2172+
{ id: 'middle', provider: 'aws', cloud_account_id: 'acct-1', service: 'ec2', resource_type: 't3.medium', region: 'us-east-1', engine: '', count: 1, term: 3, savings: 600, upfront_cost: 7200 },
2173+
];
2174+
(api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: recs, regions: [] });
2175+
await loadRecommendations();
2176+
2177+
const selectAll = document.getElementById('select-all-recs') as HTMLInputElement;
2178+
selectAll.checked = true;
2179+
selectAll.dispatchEvent(new Event('change'));
2180+
2181+
// Exactly one add call (single cell, one variant picked).
2182+
const addCalls = (state.addSelectedRecommendation as jest.Mock).mock.calls;
2183+
expect(addCalls).toHaveLength(1);
2184+
// The "middle" variant has the highest effective ($400/mo > $300 > $200) — picked.
2185+
expect(addCalls[0]![0]).toBe('middle');
2186+
});
2187+
});

frontend/src/recommendations.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,55 @@ const SORTABLE_STRING_COLUMNS: Record<string, (r: LocalRecommendation) => string
142142
region: (r) => r.region ?? '',
143143
};
144144

145+
// cellKey identifies the physical-resource cell a rec belongs to.
146+
// After PR #195's per-(term, payment) fan-out, a single physical resource
147+
// produces up to 6 alternative rec rows (2 terms × 3 payments). The
148+
// `(provider, cloud_account_id, service, region, resource_type, engine)`
149+
// prefix is the cell — recs sharing this prefix are alternatives, not
150+
// additions. Same prefix the scheduler ID encoding uses
151+
// (scheduler.go:856-858, PR #189) but without the (term, payment) suffix.
152+
//
153+
// Used by issue #224's radio enforcement: at most one variant per cell
154+
// can be selected at any time.
155+
function cellKey(rec: LocalRecommendation): string {
156+
return `${rec.provider}|${rec.cloud_account_id ?? ''}|${rec.service}|${rec.region}|${rec.resource_type}|${rec.engine ?? ''}`;
157+
}
158+
159+
// pickBestVariantPerCell collapses a list of recs to one rec per cell,
160+
// choosing the variant with the highest effective monthly savings.
161+
//
162+
// Effective savings amortizes the upfront cost across the term:
163+
// effective = savings - (upfront_cost / (term * 12))
164+
//
165+
// Two `(savings, upfront_cost, term)` tuples that look identical on raw
166+
// `savings` can score very differently on the amortized number — e.g. a
167+
// 3yr all-upfront commitment with a large lump-sum upfront has a much
168+
// lower effective monthly savings than a no-upfront variant with the
169+
// same headline savings. Picking by amortization picks the variant
170+
// that's actually best for the operator's wallet over the term.
171+
//
172+
// Used by issue #224's `select-all` handler. Sibling issue #223 will
173+
// replace this tiebreaker with "matches resolved GlobalConfig.DefaultTerm
174+
// + DefaultPayment" once that lands; until then, amortized savings is
175+
// the right deterministic default.
176+
function pickBestVariantPerCell(recs: readonly LocalRecommendation[]): LocalRecommendation[] {
177+
const byCell = new Map<string, LocalRecommendation>();
178+
const effective = (r: LocalRecommendation): number => {
179+
// Defensive clamp: if a rec arrives with term=0 from a malformed
180+
// fixture, treat it as 1yr so the division doesn't blow up.
181+
const monthsInTerm = Math.max(1, r.term * 12);
182+
return r.savings - (r.upfront_cost / monthsInTerm);
183+
};
184+
for (const r of recs) {
185+
const k = cellKey(r);
186+
const existing = byCell.get(k);
187+
if (!existing || effective(r) > effective(existing)) {
188+
byCell.set(k, r);
189+
}
190+
}
191+
return Array.from(byCell.values());
192+
}
193+
145194
const SORT_HEADER_LABELS: Record<string, string> = {
146195
provider: 'Provider',
147196
account: 'Account',
@@ -1930,7 +1979,16 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void {
19301979
if (selectAllCheckbox) {
19311980
selectAllCheckbox.addEventListener('change', () => {
19321981
if (selectAllCheckbox.checked) {
1933-
recommendations.forEach((r) => state.addSelectedRecommendation(r.id));
1982+
// Issue #224: select-all picks ONE variant per cell (highest-effective-
1983+
// savings) rather than every visible row. After PR #195's per-(term,
1984+
// payment) fan-out, naive "select every row" produces 6× the intended
1985+
// commitments per resource — wrong purchase intent. Clear current
1986+
// selection first so a stale choice from a different filter context
1987+
// doesn't bleed through.
1988+
state.clearSelectedRecommendations();
1989+
for (const r of pickBestVariantPerCell(recommendations)) {
1990+
state.addSelectedRecommendation(r.id);
1991+
}
19341992
} else {
19351993
state.clearSelectedRecommendations();
19361994
}
@@ -1942,11 +2000,26 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void {
19422000
// changes so a stale selection from a previous filter is a no-op
19432001
// once the user narrows, rather than pointing at whichever rec
19442002
// happens to occupy the old index position.
2003+
//
2004+
// Issue #224: enforce one-variant-per-cell radio behaviour on check.
2005+
// When the user checks a variant, deselect any other variant of the
2006+
// same cell that's already selected — a single physical resource
2007+
// can only carry one (term, payment) commitment at a time.
19452008
container.querySelectorAll<HTMLInputElement>('input[data-rec-id]').forEach(cb => {
19462009
cb.addEventListener('change', () => {
19472010
const id = cb.dataset['recId'] || '';
19482011
if (!id) return;
19492012
if (cb.checked) {
2013+
const newRec = recommendations.find((r) => r.id === id);
2014+
if (newRec) {
2015+
const newCell = cellKey(newRec);
2016+
const selected = state.getSelectedRecommendationIDs();
2017+
for (const r of recommendations) {
2018+
if (r.id !== id && selected.has(r.id) && cellKey(r) === newCell) {
2019+
state.removeSelectedRecommendation(r.id);
2020+
}
2021+
}
2022+
}
19502023
state.addSelectedRecommendation(id);
19512024
} else {
19522025
state.removeSelectedRecommendation(id);

0 commit comments

Comments
 (0)