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
201 changes: 86 additions & 115 deletions frontend/src/__tests__/recommendations.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Recommendations module tests
*/
import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, clearRecommendationDetailCache, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, onDemandMonthly, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight, scaleCost, formatCostForPeriod, periodSuffix, loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState, TOGGLEABLE_COLUMNS, COLUMN_DEFS } from '../recommendations';
import { loadRecommendations, openPurchaseModal, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, refreshRecommendations, setupRecommendationsHandlers, pickBestVariantPerCell, seedGlobalDefaults, effectiveMonthlySavings, effectiveSavingsPct, onDemandMonthly, groupRecsByCell, cellSummary, pageLevelRange, resetExpandedCells, resetAutoRefreshInFlight, scaleCost, formatCostForPeriod, periodSuffix, loadColumnVisibility, saveColumnVisibility, resetColumnVisibilityState, TOGGLEABLE_COLUMNS, COLUMN_DEFS } from '../recommendations';
import type { CostPeriod } from '../state';

// Mock the api module
Expand Down Expand Up @@ -1036,140 +1036,111 @@ describe('Recommendations Module', () => {
expect(document.querySelector('.recommendations-bulk-toolbar')).toBeNull();
});

test('clicking a row opens the detail drawer with that recommendation', async () => {
await loadRecommendations();
// Simulate clicking the first data row (not on a checkbox / button).
const firstRow = document.querySelector<HTMLTableRowElement>('tr.recommendation-row');
const cell = firstRow?.querySelectorAll('td')[3]; // Service cell — safe to click
cell?.click();
const drawer = document.querySelector('.detail-drawer');
expect(drawer).not.toBeNull();
expect(drawer?.querySelector('h3')?.textContent).toContain('AWS');
});

test('ESC closes the detail drawer', async () => {
await loadRecommendations();
const firstRow = document.querySelector<HTMLTableRowElement>('tr.recommendation-row');
const cell = firstRow?.querySelectorAll('td')[3];
cell?.click();
expect(document.querySelector('.detail-drawer')).not.toBeNull();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(document.querySelector('.detail-drawer')).toBeNull();
});

describe('drawer fetches detail from /api/recommendations/:id/detail (issue #44)', () => {
// The detail-fetch mock lives on the api/recommendations module
// so the test can assert call shape without round-tripping
// through apiRequest.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const recApi = require('../api/recommendations') as { getRecommendationDetail: jest.Mock };

beforeEach(() => {
// Real timers — the drawer's fetch uses microtasks (Promise
// resolution) which jest's fake timers don't auto-advance.
jest.useRealTimers();
clearRecommendationDetailCache();
recApi.getRecommendationDetail.mockReset();
});

afterEach(() => {
jest.useFakeTimers();
});

test('drawer fetches detail once per id and renders backend confidence + provenance', async () => {
recApi.getRecommendationDetail.mockResolvedValue({
id: 'rec-15',
usage_history: [],
confidence_bucket: 'high',
provenance_note: 'AWS ec2 recommendation APIs · last collected 2026-04-24T12:00:00Z',
});
// Issue #344 T4': row-click toggles selection. The previous
// row-click → openDetailDrawer behaviour was dropped (per plan.md
// §T4) — the drawer payload duplicated the table, with
// backend-deferred fields the only differentiators. Tests below
// cover the new selection-toggle contract.
//
// The shared `wireSelection` helper hooks the state mocks to a real
// Set so addSelectedRecommendation actually surfaces in subsequent
// getSelectedRecommendationIDs reads — without this, re-renders see
// an empty selection and the row checkbox renders unchecked.
describe('row-click selection (T4′)', () => {
function wireSelection(): Set<string> {
const selected = new Set<string>();
(state.getSelectedRecommendationIDs as jest.Mock).mockImplementation(() => new Set(selected));
(state.addSelectedRecommendation as jest.Mock).mockImplementation((id: string) => { selected.add(id); });
(state.removeSelectedRecommendation as jest.Mock).mockImplementation((id: string) => { selected.delete(id); });
(state.clearSelectedRecommendations as jest.Mock).mockImplementation(() => { selected.clear(); });
(state.getRecommendations as jest.Mock).mockReturnValue(twoRecs);
(state.getVisibleRecommendations as jest.Mock).mockReturnValue(twoRecs);
return selected;
}

test('clicking a non-interactive cell on a row toggles the row checkbox', async () => {
wireSelection();
await loadRecommendations();
const firstRow = document.querySelector<HTMLTableRowElement>('tr.recommendation-row');
firstRow?.querySelectorAll('td')[3]?.click();

// Allow the .then() handler to run.
await Promise.resolve();
await Promise.resolve();
const cb = firstRow?.querySelector<HTMLInputElement>('input[type="checkbox"][data-rec-id]');
expect(cb).not.toBeNull();
expect(cb!.checked).toBe(false);
const recId = cb!.dataset['recId']!;

expect(recApi.getRecommendationDetail).toHaveBeenCalledTimes(1);
// Default sort is savings desc → rec-15 ($1500) renders first.
expect(recApi.getRecommendationDetail).toHaveBeenCalledWith('rec-15');

const badge = document.querySelector('.detail-drawer .confidence-badge');
expect(badge?.classList.contains('confidence-high')).toBe(true);
expect(badge?.textContent).toBe('High');
// Service cell (index 3) is non-interactive — safe to click.
firstRow?.querySelectorAll('td')[3]?.click();

const provenance = document.querySelector('.detail-drawer .detail-drawer-note');
expect(provenance?.textContent).toContain('last collected 2026-04-24T12:00:00Z');
const cbAfter = document.querySelector<HTMLInputElement>(
`tr.recommendation-row input[data-rec-id="${recId}"]`,
);
expect(cbAfter?.checked).toBe(true);
});

test('empty usage_history renders the "not yet available" placeholder, not a broken chart', async () => {
recApi.getRecommendationDetail.mockResolvedValue({
id: 'rec-15',
usage_history: [],
confidence_bucket: 'medium',
provenance_note: 'AWS ec2 recommendation APIs.',
});

test('clicking a row a second time unselects it', async () => {
wireSelection();
await loadRecommendations();
const firstRow = document.querySelector<HTMLTableRowElement>('tr.recommendation-row');
const cb = firstRow?.querySelector<HTMLInputElement>('input[type="checkbox"][data-rec-id]');
const recId = cb!.dataset['recId']!;

firstRow?.querySelectorAll('td')[3]?.click();
await Promise.resolve();
await Promise.resolve();

// No SVG sparkline — degraded path.
expect(document.querySelector('.detail-drawer-sparkline')).toBeNull();
// Placeholder note present.
const usageNote = document.querySelector('.detail-drawer-usage .detail-drawer-note-muted');
expect(usageNote?.textContent).toBe('Usage history not yet available.');
});
let cbAfter = document.querySelector<HTMLInputElement>(
`tr.recommendation-row input[data-rec-id="${recId}"]`,
);
expect(cbAfter?.checked).toBe(true);

test('non-empty usage_history renders an inline SVG sparkline', async () => {
recApi.getRecommendationDetail.mockResolvedValue({
id: 'rec-15',
usage_history: [
{ timestamp: '2026-04-23T00:00:00Z', cpu_pct: 12, mem_pct: 30 },
{ timestamp: '2026-04-23T01:00:00Z', cpu_pct: 18, mem_pct: 32 },
{ timestamp: '2026-04-23T02:00:00Z', cpu_pct: 25, mem_pct: 40 },
],
confidence_bucket: 'high',
provenance_note: 'AWS ec2 recommendation APIs.',
});
// Second click on the (post-rerender) row toggles back off.
const rerenderedRow = cbAfter!.closest('tr')!;
rerenderedRow.querySelectorAll('td')[3]?.click();

cbAfter = document.querySelector<HTMLInputElement>(
`tr.recommendation-row input[data-rec-id="${recId}"]`,
);
expect(cbAfter?.checked).toBe(false);
});

test('row-click does NOT trigger when the click originates on an interactive descendant', async () => {
wireSelection();
await loadRecommendations();
const firstRow = document.querySelector<HTMLTableRowElement>('tr.recommendation-row');
firstRow?.querySelectorAll('td')[3]?.click();
await Promise.resolve();
await Promise.resolve();

const svg = document.querySelector('.detail-drawer-sparkline');
expect(svg).not.toBeNull();
// Two paths (CPU + memory).
expect(svg?.querySelectorAll('path').length).toBe(2);
const cb = firstRow?.querySelector<HTMLInputElement>('input[type="checkbox"][data-rec-id]');
expect(cb!.checked).toBe(false);

// Inject a synthetic action button into the first row to model
// any inline per-row CTA (the production table doesn't currently
// render one, but the click-filter rule must still hold).
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = 'Inline action';
firstRow!.querySelectorAll('td')[3]!.appendChild(btn);

btn.click();

// Selection state must NOT change just because the click
// bubbled from the button to the row.
const cbAfter = document.querySelector<HTMLInputElement>(
`tr.recommendation-row input[data-rec-id="${cb!.dataset['recId']}"]`,
);
expect(cbAfter?.checked).toBe(false);
});

test('repeated open of same drawer reuses the cached detail (one fetch per id)', async () => {
recApi.getRecommendationDetail.mockResolvedValue({
id: 'rec-15',
usage_history: [],
confidence_bucket: 'low',
provenance_note: 'AWS ec2 recommendation APIs.',
});

test('clicking the checkbox itself does not double-toggle (native click handles it)', async () => {
wireSelection();
await loadRecommendations();
const firstRow = document.querySelector<HTMLTableRowElement>('tr.recommendation-row');
firstRow?.querySelectorAll('td')[3]?.click();
await Promise.resolve();
await Promise.resolve();
const cb = firstRow?.querySelector<HTMLInputElement>('input[type="checkbox"][data-rec-id]')!;
expect(cb.checked).toBe(false);

// Close and re-open the same drawer.
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
firstRow?.querySelectorAll('td')[3]?.click();
await Promise.resolve();
await Promise.resolve();
// Native click on a <input type="checkbox"> toggles checked
// before the click event fires. Our row-click handler must skip
// when the originating target is the checkbox so we don't
// re-toggle it back to its prior state.
cb.click();

expect(recApi.getRecommendationDetail).toHaveBeenCalledTimes(1);
const cbAfter = document.querySelector<HTMLInputElement>(
`tr.recommendation-row input[data-rec-id="${cb.dataset['recId']}"]`,
);
expect(cbAfter?.checked).toBe(true);
});
});
});
Expand Down
Loading
Loading