From dc31e546ba401354917d78328b6fd9623d763f1b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 16:16:14 +0200 Subject: [PATCH 1/3] refactor(frontend/state): add RiExchangeColumnId + riExchangeColumnFilters slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the recommendations slice shape (extracted in #570) so the RI Exchange reshape-recommendations table can wire inline column filters via the shared applyColumnFilters helper. Slice is independent of the recommendations slice — column-id shape is reshape-specific (source/target instance types, normalized units, utilization %), and persistence stays out of scope on this iteration to match the existing pattern. Refs #166. --- frontend/src/state.ts | 49 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/frontend/src/state.ts b/frontend/src/state.ts index b81351bd1..9f20df177 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -244,6 +244,55 @@ export function setCostPeriod(period: CostPeriod): void { } } +// --------------------------------------------------------------------------- +// RI Exchange per-column filters (issue #166 follow-up to merged #570). +// +// Scoped to the RI Exchange reshape-recommendations table. Independent of +// the recommendations slice so the two tabs don't fight over column-id +// shape — RI Exchange's columns are reshape-specific (source/target +// instance types, normalized units, utilization %). +// In-memory only; resets on page reload. Persistence is out of scope for +// this PR, same as the recommendations slice. +// --------------------------------------------------------------------------- +export type RiExchangeColumnId = + | 'source_ri_id' | 'source_instance_type' | 'target_instance_type' | 'reason' + | 'source_count' | 'target_count' | 'utilization_percent' + | 'normalized_used' | 'normalized_purchased'; + +export type RiExchangeColumnFilter = + | { kind: 'set'; values: string[] } // categorical — string-form values + | { kind: 'expr'; expr: string }; // numeric — parsed on apply + +export type RiExchangeColumnFilters = Partial< + Record +>; + +let riExchangeColumnFilters: RiExchangeColumnFilters = {}; + +export function getRiExchangeColumnFilters(): RiExchangeColumnFilters { + return { ...riExchangeColumnFilters }; +} + +export function setRiExchangeColumnFilter( + column: RiExchangeColumnId, + filter: RiExchangeColumnFilter | null, +): void { + if (filter === null) { + const next = { ...riExchangeColumnFilters }; + delete next[column]; + riExchangeColumnFilters = next; + return; + } + riExchangeColumnFilters = { + ...riExchangeColumnFilters, + [column]: filter, + }; +} + +export function clearAllRiExchangeColumnFilters(): void { + riExchangeColumnFilters = {}; +} + // --------------------------------------------------------------------------- // Per-column visibility state (issue #318). // A column id in this set is HIDDEN; an absent id is visible (default visible). From be28ac2282fa1eccaecc29282e2b7e28e5349ffa Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 16:23:09 +0200 Subject: [PATCH 2/3] feat(frontend/riexchange): inline column filters via shared lib Wires per-column filter popovers to the RI Exchange reshape-recommendations table using the helpers extracted in merged #570 (parseNumericFilter + applyColumnFilters). Categorical columns (Source RI, Source/Target instance types, Reason) get a checkbox-list popover; numeric columns (Source/Target count, Utilization %, Normalized used/purchased) get a free-text expression popover that supports `>N`, `>=N`, ` ({ // (issue #871); default to the AWS, all-accounts path used by these tests. getCurrentProvider: jest.fn(() => 'aws'), getCurrentAccountIDs: jest.fn(() => []), + // RI Exchange column-filter slice (issue #166 follow-up). Permission + // tests don't drive filter state; empty record keeps rendering on the + // happy path. + getRiExchangeColumnFilters: jest.fn(() => ({})), + setRiExchangeColumnFilter: jest.fn(), + clearAllRiExchangeColumnFilters: jest.fn(), })); import * as api from '../api'; diff --git a/frontend/src/__tests__/riexchange.test.ts b/frontend/src/__tests__/riexchange.test.ts index 10271b8b2..ccd6555d5 100644 --- a/frontend/src/__tests__/riexchange.test.ts +++ b/frontend/src/__tests__/riexchange.test.ts @@ -46,6 +46,12 @@ jest.mock('../state', () => ({ getCurrentProvider: jest.fn(() => 'aws'), getCurrentAccountIDs: jest.fn(() => []), getCurrentUser: jest.fn(() => ({ id: 'u', email: 'u@example.com', groups: ['00000000-0000-5000-8000-000000000001'] })), + // RI Exchange column-filter slice (issue #166 follow-up). Tests don't + // exercise the filter state directly; an empty record + no-op setters + // keep renderRecommendations on its happy path. + getRiExchangeColumnFilters: jest.fn(() => ({})), + setRiExchangeColumnFilter: jest.fn(), + clearAllRiExchangeColumnFilters: jest.fn(), })); import { @@ -441,6 +447,13 @@ describe('reshape recommendations table', () => { tableContainer = document.createElement('div'); tableContainer.id = 'ri-exchange-recommendations-list'; document.body.appendChild(tableContainer); + // Re-apply the column-filter mock impl after a prior test's + // jest.resetAllMocks(); without this the renderer blows up on + // Object.entries(undefined) when reading filter state. + const stateMod = jest.requireMock('../state') as { + getRiExchangeColumnFilters: jest.Mock; + }; + stateMod.getRiExchangeColumnFilters.mockReturnValue({}); }); afterEach(() => { diff --git a/frontend/src/riexchange.ts b/frontend/src/riexchange.ts index 15be4773f..1aa5219fa 100644 --- a/frontend/src/riexchange.ts +++ b/frontend/src/riexchange.ts @@ -8,6 +8,10 @@ import * as state from './state'; import { formatDate, formatDateTime, escapeHtml, formatCurrency } from './utils'; import { switchTab, switchSettingsSubTab } from './navigation'; import { confirmDialog } from './confirmDialog'; +import { + parseNumericFilter, + applyColumnFilters as applyColumnFiltersLib, +} from './lib/column-filters'; import type { ConvertibleRI, ExchangeableAzureRI, @@ -524,6 +528,387 @@ export function renderReshapeStalenessBanner( banner.appendChild(document.createTextNode(copy)); } +// ────────────────────────────────────────────── +// Column filters (issue #166 follow-up to merged #570) +// +// Wires inline column filters to the reshape-recommendations table via the +// shared lib/column-filters helpers. Categorical columns (Source RI, Current, +// Suggested, Reason) get a checkbox-list popover; numeric columns (Count, +// Utilization, Normalized Used/Purchased) get a free-text expression popover. +// Display-rounded numeric values match what the cell renders so a typed +// value like "95.0" matches the visible utilization figure. +// ────────────────────────────────────────────── + +interface RiExchangeColumnDef { + key: state.RiExchangeColumnId; + label: string; + kind: 'numeric' | 'categorical'; +} + +const RIEX_COLUMN_DEFS: readonly RiExchangeColumnDef[] = [ + { key: 'source_ri_id', label: 'Source RI', kind: 'categorical' }, + { key: 'source_count', label: 'Source count', kind: 'numeric' }, + { key: 'source_instance_type', label: 'Source instance', kind: 'categorical' }, + { key: 'target_count', label: 'Target count', kind: 'numeric' }, + { key: 'target_instance_type', label: 'Target instance', kind: 'categorical' }, + { key: 'utilization_percent', label: 'Utilization %', kind: 'numeric' }, + { key: 'normalized_used', label: 'Normalized used', kind: 'numeric' }, + { key: 'normalized_purchased', label: 'Normalized purchased', kind: 'numeric' }, + { key: 'reason', label: 'Reason', kind: 'categorical' }, +]; + +const RIEX_NUMERIC_COLUMNS: ReadonlySet = new Set( + RIEX_COLUMN_DEFS.filter((c) => c.kind === 'numeric').map((c) => c.key), +); + +function riexCategoricalCellValue( + r: import('./api').ReshapeRecommendation, + col: state.RiExchangeColumnId, +): string { + switch (col) { + case 'source_ri_id': return r.source_ri_id ?? ''; + case 'source_instance_type': return r.source_instance_type ?? ''; + case 'target_instance_type': return r.target_instance_type ?? ''; + case 'reason': return r.reason ?? ''; + // Numeric columns never reach this branch — return '' so the type matches. + case 'source_count': + case 'target_count': + case 'utilization_percent': + case 'normalized_used': + case 'normalized_purchased': return ''; + } +} + +function riexNumericCellValue( + r: import('./api').ReshapeRecommendation, + col: state.RiExchangeColumnId, +): number { + switch (col) { + case 'source_count': return r.source_count ?? 0; + case 'target_count': return r.target_count ?? 0; + case 'utilization_percent': return r.utilization_percent ?? Number.NaN; + case 'normalized_used': return r.normalized_used ?? Number.NaN; + case 'normalized_purchased': return r.normalized_purchased ?? Number.NaN; + // Categorical columns never reach this branch — NaN fails every predicate. + case 'source_ri_id': + case 'source_instance_type': + case 'target_instance_type': + case 'reason': return Number.NaN; + } +} + +// Decimal places the cell renders with — mirrors the toFixed() calls below +// in the row markup so a user typing the displayed value matches the +// rounded cell value. Counts render as integers; utilization and +// normalized units render with one decimal place. +function riexDisplayPrecision(col: state.RiExchangeColumnId): number { + switch (col) { + case 'source_count': + case 'target_count': + return 0; + case 'utilization_percent': + case 'normalized_used': + case 'normalized_purchased': + return 1; + case 'source_ri_id': + case 'source_instance_type': + case 'target_instance_type': + case 'reason': + return 0; + } +} + +function riexRoundForDisplay(n: number, precision: number): number { + if (!Number.isFinite(n)) return n; + return Number(n.toFixed(precision)); +} + +export function applyRiExchangeColumnFilters( + recs: readonly import('./api').ReshapeRecommendation[], + filters: state.RiExchangeColumnFilters, +): import('./api').ReshapeRecommendation[] { + return applyColumnFiltersLib< + import('./api').ReshapeRecommendation, + state.RiExchangeColumnId + >(recs, filters, { + categorical: riexCategoricalCellValue, + numeric: (r, col) => riexRoundForDisplay(riexNumericCellValue(r, col), riexDisplayPrecision(col)), + }); +} + +// ── Popover ─────────────────────────────────── + +interface RiexPopoverState { + column: state.RiExchangeColumnId; + el: HTMLDivElement; + checkboxes: Map; + input: HTMLInputElement | null; + errorEl: HTMLElement | null; +} + +let riexOpenPopover: RiexPopoverState | null = null; +let riexOutsideHandler: ((e: MouseEvent) => void) | null = null; +let riexEscHandler: ((e: KeyboardEvent) => void) | null = null; + +function riexLabelFor(col: state.RiExchangeColumnId): string { + return RIEX_COLUMN_DEFS.find((c) => c.key === col)?.label ?? col; +} + +function riexDistinctValues( + recs: readonly import('./api').ReshapeRecommendation[], + col: state.RiExchangeColumnId, +): string[] { + const seen = new Set(); + for (const r of recs) seen.add(riexCategoricalCellValue(r, col)); + return Array.from(seen).sort((a, b) => { + if (a === '' && b !== '') return -1; + if (a !== '' && b === '') return 1; + return a.localeCompare(b); + }); +} + +function riexPositionPopover(popover: HTMLElement, anchor: HTMLElement): void { + const rect = anchor.getBoundingClientRect(); + popover.style.display = 'block'; + const popRect = popover.getBoundingClientRect(); + const margin = 8; + let top = rect.bottom + 4; + if (top + popRect.height > window.innerHeight - margin) { + top = Math.max(margin, rect.top - popRect.height - 4); + } + let left = rect.left; + if (left + popRect.width > window.innerWidth - margin) { + left = Math.max(margin, window.innerWidth - margin - popRect.width); + } + popover.style.position = 'absolute'; + popover.style.top = `${top + window.scrollY}px`; + popover.style.left = `${left + window.scrollX}px`; +} + +function riexBuildPopover( + column: state.RiExchangeColumnId, + recs: readonly import('./api').ReshapeRecommendation[], +): RiexPopoverState { + const popover = document.createElement('div'); + popover.className = 'column-filter-popover'; + popover.setAttribute('role', 'dialog'); + popover.setAttribute('aria-modal', 'false'); + + const headingId = `riex-column-filter-heading-${column}`; + popover.setAttribute('aria-labelledby', headingId); + + const heading = document.createElement('h3'); + heading.id = headingId; + heading.className = 'column-filter-heading'; + heading.textContent = `Filter ${riexLabelFor(column)}`; + popover.appendChild(heading); + + const checkboxes = new Map(); + let input: HTMLInputElement | null = null; + let errorEl: HTMLElement | null = null; + let commitAllRef: ((target: boolean) => void) | null = null; + + if (RIEX_NUMERIC_COLUMNS.has(column)) { + const label = document.createElement('label'); + label.className = 'column-filter-numeric-label'; + label.textContent = 'Expression'; + input = document.createElement('input'); + input.type = 'text'; + input.className = 'column-filter-numeric-input'; + input.placeholder = 'e.g. >50, 70..95, 1'; + input.setAttribute('aria-describedby', `riex-column-filter-error-${column}`); + const current = state.getRiExchangeColumnFilters()[column]; + if (current && current.kind === 'expr') input.value = current.expr; + label.appendChild(input); + popover.appendChild(label); + + errorEl = document.createElement('div'); + errorEl.id = `riex-column-filter-error-${column}`; + errorEl.className = 'column-filter-error'; + errorEl.setAttribute('role', 'status'); + popover.appendChild(errorEl); + + const commit = (): void => { + const expr = input!.value.trim(); + if (expr === '') { + state.setRiExchangeColumnFilter(column, null); + errorEl!.textContent = ''; + rerenderReshape(); + return; + } + const parsed = parseNumericFilter(expr); + if (!parsed.ok) { + errorEl!.textContent = parsed.error; + return; + } + errorEl!.textContent = ''; + state.setRiExchangeColumnFilter(column, { kind: 'expr', expr }); + rerenderReshape(); + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commit(); + } + }); + } else { + const distinct = riexDistinctValues(recs, column); + const current = state.getRiExchangeColumnFilters()[column]; + const activeSet: ReadonlySet | null = + current && current.kind === 'set' ? new Set(current.values) : null; + + const allLabel = document.createElement('label'); + allLabel.className = 'column-filter-all'; + const allBox = document.createElement('input'); + allBox.type = 'checkbox'; + allBox.dataset['role'] = 'all'; + allLabel.appendChild(allBox); + const allText = document.createElement('span'); + allText.textContent = '(All)'; + allLabel.appendChild(allText); + popover.appendChild(allLabel); + + const list = document.createElement('div'); + list.className = 'column-filter-list'; + for (const value of distinct) { + const itemLabel = document.createElement('label'); + itemLabel.className = 'column-filter-item'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.dataset['value'] = value; + cb.checked = activeSet === null ? true : activeSet.has(value); + itemLabel.appendChild(cb); + const text = document.createElement('span'); + text.textContent = value === '' ? '(empty)' : value; + itemLabel.appendChild(text); + list.appendChild(itemLabel); + checkboxes.set(value, cb); + } + popover.appendChild(list); + + const updateAllTriState = (): void => { + const total = checkboxes.size; + let checked = 0; + checkboxes.forEach((cb) => { if (cb.checked) checked++; }); + allBox.indeterminate = checked > 0 && checked < total; + allBox.checked = checked === total && total > 0; + }; + updateAllTriState(); + + const commit = (): void => { + const selected: string[] = []; + checkboxes.forEach((cb, value) => { if (cb.checked) selected.push(value); }); + if (selected.length === checkboxes.size) { + state.setRiExchangeColumnFilter(column, null); + } else { + state.setRiExchangeColumnFilter(column, { kind: 'set', values: selected }); + } + updateAllTriState(); + rerenderReshape(); + }; + + const commitAll = (target: boolean): void => { + checkboxes.forEach((cb) => { cb.checked = target; }); + if (target) { + state.setRiExchangeColumnFilter(column, null); + } else { + state.setRiExchangeColumnFilter(column, { kind: 'set', values: [] }); + } + updateAllTriState(); + rerenderReshape(); + }; + commitAllRef = commitAll; + + checkboxes.forEach((cb) => { cb.addEventListener('change', commit); }); + allBox.addEventListener('change', () => { commitAll(allBox.checked); }); + } + + const footer = document.createElement('div'); + footer.className = 'column-filter-footer'; + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'column-filter-clear'; + clearBtn.textContent = 'Clear'; + clearBtn.addEventListener('click', () => { + if (input) { + state.setRiExchangeColumnFilter(column, null); + input.value = ''; + if (errorEl) errorEl.textContent = ''; + rerenderReshape(); + } else { + commitAllRef?.(false); + } + }); + footer.appendChild(clearBtn); + popover.appendChild(footer); + + return { column, el: popover, checkboxes, input, errorEl }; +} + +function riexCloseOpenPopover(): void { + if (!riexOpenPopover) return; + const { column, el } = riexOpenPopover; + el.remove(); + riexOpenPopover = null; + if (riexOutsideHandler) { + document.removeEventListener('mousedown', riexOutsideHandler); + riexOutsideHandler = null; + } + if (riexEscHandler) { + document.removeEventListener('keydown', riexEscHandler); + riexEscHandler = null; + } + const trigger = document.querySelector( + `#ri-exchange-recommendations-list .column-filter-btn[data-column="${column}"]`, + ); + if (trigger) trigger.setAttribute('aria-expanded', 'false'); +} + +function riexOpenPopoverFor(column: state.RiExchangeColumnId, anchor: HTMLElement): void { + if (riexOpenPopover && !riexOpenPopover.el.isConnected) { + riexCloseOpenPopover(); + } + if (riexOpenPopover && riexOpenPopover.column === column) { + riexCloseOpenPopover(); + return; + } + if (riexOpenPopover) riexCloseOpenPopover(); + + const built = riexBuildPopover(column, currentRecommendations); + document.body.appendChild(built.el); + riexOpenPopover = built; + riexPositionPopover(built.el, anchor); + anchor.setAttribute('aria-expanded', 'true'); + + if (!riexOutsideHandler) { + riexOutsideHandler = (e: MouseEvent): void => { + if (!riexOpenPopover) return; + const target = e.target as Node | null; + if (!target) return; + if (riexOpenPopover.el.contains(target)) return; + if (target instanceof Element && target.closest('.column-filter-btn')) return; + riexCloseOpenPopover(); + }; + document.addEventListener('mousedown', riexOutsideHandler); + } + if (!riexEscHandler) { + riexEscHandler = (e: KeyboardEvent): void => { + if (e.key === 'Escape') riexCloseOpenPopover(); + }; + document.addEventListener('keydown', riexEscHandler); + } + + const firstFocusable = built.input + ?? built.el.querySelector('input[type="checkbox"]'); + firstFocusable?.focus(); +} + +function rerenderReshape(): void { + const container = document.getElementById('ri-exchange-recommendations-list'); + if (container) renderRecommendations(container); +} + function renderRecommendations(container: HTMLElement): void { if (!currentRecommendations || currentRecommendations.length === 0) { // The "well-utilized" copy is only truthful when the RI fleet actually @@ -540,22 +925,42 @@ function renderRecommendations(container: HTMLElement): void { // Issue #365: same admin-only gate as the convertible-RI table. const canExchange = canAccess('admin', '*'); + // Apply per-column filters before rendering. Each filter is ANDed with + // the others; broken numeric expressions are skipped so the popover can + // surface the error without forcing the user to clear the field first. + const filters = state.getRiExchangeColumnFilters(); + const visible = applyRiExchangeColumnFilters(currentRecommendations, filters); + + const filterBtn = (column: state.RiExchangeColumnId): string => { + const active = filters[column] ? ' active' : ''; + const lbl = riexLabelFor(column); + const label = filters[column] ? `Filter ${lbl} — currently active` : `Filter ${lbl}`; + return ``; + }; + + // Render an indexable list so per-row Exchange buttons can find the + // matching original recommendation (idx into currentRecommendations). + const visibleWithIdx = visible.map((rec) => ({ + rec, + idx: currentRecommendations.indexOf(rec), + })); + container.innerHTML = ` - - - + + + - - - + + + ${canExchange ? '' : ''} - ${currentRecommendations.map((rec, idx) => { + ${visibleWithIdx.map(({ rec, idx }) => { const utilClass = rec.utilization_percent >= 95 ? 'util-green' : rec.utilization_percent >= 70 ? 'util-yellow' : 'util-red'; const altCell = renderAlternativesCell(rec.alternative_targets); return ` @@ -576,6 +981,19 @@ function renderRecommendations(container: HTMLElement): void {
Source RICurrentSuggestedSource RI${filterBtn('source_ri_id')}Current${filterBtn('source_count')}${filterBtn('source_instance_type')}Suggested${filterBtn('target_count')}${filterBtn('target_instance_type')} AlternativesUtilizationNormalized UnitsReasonUtilization${filterBtn('utilization_percent')}Normalized Units${filterBtn('normalized_used')}${filterBtn('normalized_purchased')}Reason${filterBtn('reason')}Actions
`; + // Wire per-column filter buttons. e.stopPropagation prevents the + // surrounding from also handling the click (no sort handler today, + // but matches the pattern in recommendations.ts so future -level + // handlers won't conflict). + container.querySelectorAll('.column-filter-btn').forEach((btn) => { + const column = btn.dataset['column'] as state.RiExchangeColumnId | undefined; + if (!column) return; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + riexOpenPopoverFor(column, btn); + }); + }); + // Attach "Exchange" handlers. Same as renderRIsTable: for non-admin // sessions the selector matches zero elements and this loop is a no-op. container.querySelectorAll('[data-action="fill-quote"]').forEach(btn => { From 8bc017dc4f4a6448f03398455f7d888244e67dfc Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 28 May 2026 16:24:35 +0200 Subject: [PATCH 3/3] test(frontend/riexchange): column-filter regression suite Adds focused regression coverage for the RI Exchange filter wiring on top of the shared lib (issue #166 follow-up to merged #570): * empty filter record returns a defensive clone of the input * numeric expression filter narrows by predicate * categorical set filter narrows by membership * multiple filters AND together across kinds * broken numeric expressions are skipped rather than treated as match-none * numeric predicates compare against the display-rounded cell value (utilization toFixed(1) regression guard) * clearing a column drops its narrowing The popover / state-slice / button-rendering wiring is exercised by the existing riexchange test suite; this file pins the pure-function contract the lib + extractor + precision composition relies on. Refs #166. --- .../riexchange-column-filters.test.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 frontend/src/__tests__/riexchange-column-filters.test.ts diff --git a/frontend/src/__tests__/riexchange-column-filters.test.ts b/frontend/src/__tests__/riexchange-column-filters.test.ts new file mode 100644 index 000000000..10eb0c0d9 --- /dev/null +++ b/frontend/src/__tests__/riexchange-column-filters.test.ts @@ -0,0 +1,130 @@ +/** + * Regression suite for the RI Exchange column-filter wiring (issue #166 + * follow-up to merged #570). + * + * Covers the contract that `applyRiExchangeColumnFilters` is the + * canonical filter pipeline for the reshape-recommendations table: + * - categorical set-membership narrows by exact match; + * - numeric expressions narrow by the parsed predicate; + * - multiple column filters AND together; + * - invalid numeric expressions are skipped (not treated as match-none); + * - clearing a column drops its narrowing. + * + * The renderer wiring on top of this (popover, state slice mutations, + * filter button) is exercised indirectly by the existing riexchange + * test suite — those tests assert the table still renders rows with the + * default empty-filter state. The contract here is the pure function + * that decides which rows pass. + */ + +// Mock api/state defensively so the riexchange module's transitive +// imports don't drag in jsdom plumbing we don't need for a pure-fn test. +jest.mock('../api', () => ({ + listConvertibleRIs: jest.fn(), + getRIUtilization: jest.fn(), + getReshapeRecommendations: jest.fn(), + getExchangeQuote: jest.fn(), + executeExchange: jest.fn(), + getRIExchangeHistory: jest.fn(), + getRIExchangeConfig: jest.fn(), + updateRIExchangeConfig: jest.fn(), + listTargetOfferings: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../navigation', () => ({ + switchTab: jest.fn(), + switchSettingsSubTab: jest.fn(), +})); + +import type { ReshapeRecommendation } from '../api'; +import { applyRiExchangeColumnFilters } from '../riexchange'; +import type { RiExchangeColumnFilters } from '../state'; + +function makeRec(overrides: Partial = {}): ReshapeRecommendation { + return { + source_ri_id: 'ri-a', + source_instance_type: 'm5.xlarge', + source_count: 4, + target_instance_type: 'm5.large', + target_count: 8, + utilization_percent: 50, + normalized_used: 4, + normalized_purchased: 8, + reason: 'underutilized', + ...overrides, + }; +} + +describe('applyRiExchangeColumnFilters', () => { + const recs: ReshapeRecommendation[] = [ + makeRec({ source_ri_id: 'ri-a', source_instance_type: 'm5.xlarge', utilization_percent: 50, target_count: 2 }), + makeRec({ source_ri_id: 'ri-b', source_instance_type: 'm5.xlarge', utilization_percent: 95, target_count: 8 }), + makeRec({ source_ri_id: 'ri-c', source_instance_type: 'c6i.large', utilization_percent: 30, target_count: 1 }), + ]; + + test('empty filter record returns a clone of the input', () => { + const out = applyRiExchangeColumnFilters(recs, {}); + expect(out).toEqual(recs); + expect(out).not.toBe(recs); + }); + + test('numeric expression filter narrows by predicate', () => { + const filters: RiExchangeColumnFilters = { + utilization_percent: { kind: 'expr', expr: '>=70' }, + }; + const out = applyRiExchangeColumnFilters(recs, filters); + expect(out.map((r) => r.source_ri_id)).toEqual(['ri-b']); + }); + + test('categorical set filter narrows by membership', () => { + const filters: RiExchangeColumnFilters = { + source_instance_type: { kind: 'set', values: ['m5.xlarge'] }, + }; + const out = applyRiExchangeColumnFilters(recs, filters); + expect(out.map((r) => r.source_ri_id)).toEqual(['ri-a', 'ri-b']); + }); + + test('multiple filters AND together across categorical + numeric', () => { + const filters: RiExchangeColumnFilters = { + source_instance_type: { kind: 'set', values: ['m5.xlarge'] }, + target_count: { kind: 'expr', expr: '>5' }, + }; + const out = applyRiExchangeColumnFilters(recs, filters); + // Only ri-b is m5.xlarge AND target_count>5 + expect(out.map((r) => r.source_ri_id)).toEqual(['ri-b']); + }); + + test('broken numeric expression is skipped (not match-none)', () => { + const filters: RiExchangeColumnFilters = { + utilization_percent: { kind: 'expr', expr: '>>oops' }, + }; + const out = applyRiExchangeColumnFilters(recs, filters); + // Parse fails -> filter is skipped -> every row passes. + expect(out).toHaveLength(recs.length); + }); + + test('numeric filter compares against the display-rounded value', () => { + // utilization is rendered with toFixed(1); a row whose raw value + // would round to 95.0 must match an exact-value filter "95.0". + const rec = makeRec({ source_ri_id: 'ri-x', utilization_percent: 94.96 }); + const filters: RiExchangeColumnFilters = { + utilization_percent: { kind: 'expr', expr: '95.0' }, + }; + const out = applyRiExchangeColumnFilters([rec], filters); + expect(out).toHaveLength(1); + }); + + test('clearing a column (no entry) means no narrowing on that column', () => { + // Simulate the "Clear" footer button: drop the column entry from + // the filter record. Remaining filters still apply. + const filters: RiExchangeColumnFilters = { + source_instance_type: { kind: 'set', values: ['m5.xlarge'] }, + }; + const out = applyRiExchangeColumnFilters(recs, filters); + expect(out.map((r) => r.source_ri_id)).toEqual(['ri-a', 'ri-b']); + + // Now clear by passing an empty filter record. + const cleared = applyRiExchangeColumnFilters(recs, {}); + expect(cleared).toHaveLength(recs.length); + }); +});