diff --git a/frontend/src/__tests__/serviceFilters.test.ts b/frontend/src/__tests__/serviceFilters.test.ts new file mode 100644 index 000000000..b778e9ce6 --- /dev/null +++ b/frontend/src/__tests__/serviceFilters.test.ts @@ -0,0 +1,157 @@ +/** + * Per-service recommendation-filter controls (serviceFilters.ts). + * + * Covers the GUI surface for the CLI filter knobs: + * - parseCsvList / joinCsvList round-trip + de-dup + trim + lowercase. + * - injectServiceFilterControls is idempotent and finds the card. + * - populate fills inputs from a ServiceConfig (or clears when absent). + * - read returns parsed values, validates min-count, and errors loudly on + * a bad min-count instead of silently coercing. + */ +import { + parseCsvList, + joinCsvList, + filterInputId, + serviceFilterInputIds, + injectServiceFilterControls, + populateServiceFilterControls, + readServiceFilterControls, + isServiceFilterError, + type ServiceFilterTarget, +} from '../serviceFilters'; +import type { ServiceConfig } from '../api/types'; + +const target: ServiceFilterTarget = { provider: 'aws', service: 'rds', termId: 'aws-rds-term' }; + +function seedCard(): void { + document.body.innerHTML = ` +
+
RDS
+ +
`; +} + +describe('parseCsvList / joinCsvList', () => { + it('splits on comma and whitespace, trims, lowercases, de-dups', () => { + expect(parseCsvList('MySQL, postgres mysql,, AURORA')).toEqual(['mysql', 'postgres', 'aurora']); + }); + it('returns [] for empty / whitespace-only input', () => { + expect(parseCsvList('')).toEqual([]); + expect(parseCsvList(' , , ')).toEqual([]); + }); + it('joins back to the comma-separated display form', () => { + expect(joinCsvList(['mysql', 'postgres'])).toBe('mysql, postgres'); + expect(joinCsvList(undefined)).toBe(''); + }); +}); + +describe('id helpers', () => { + it('builds deterministic input ids', () => { + expect(filterInputId('aws', 'rds', 'include-engines')).toBe('aws-rds-include-engines'); + }); + it('lists all owned input ids including min-count', () => { + const ids = serviceFilterInputIds(target); + expect(ids).toContain('aws-rds-include-engines'); + expect(ids).toContain('aws-rds-exclude-regions'); + expect(ids).toContain('aws-rds-min-count'); + expect(ids).toHaveLength(7); + }); +}); + +describe('injectServiceFilterControls', () => { + beforeEach(seedCard); + + it('injects the panel and all inputs once', () => { + expect(injectServiceFilterControls(target)).toBe(true); + for (const id of serviceFilterInputIds(target)) { + expect(document.getElementById(id)).not.toBeNull(); + } + // min-count input is a number input with min=0 + const minEl = document.getElementById('aws-rds-min-count') as HTMLInputElement; + expect(minEl.type).toBe('number'); + expect(minEl.min).toBe('0'); + }); + + it('is idempotent (no duplicate panel on re-run)', () => { + expect(injectServiceFilterControls(target)).toBe(true); + expect(injectServiceFilterControls(target)).toBe(false); + expect(document.querySelectorAll('.service-filter-panel')).toHaveLength(1); + }); + + it('returns false when the card is missing', () => { + document.body.innerHTML = ''; + expect(injectServiceFilterControls(target)).toBe(false); + }); +}); + +describe('populate + read round-trip', () => { + beforeEach(() => { + seedCard(); + injectServiceFilterControls(target); + }); + + it('populates inputs from a ServiceConfig', () => { + const svc: ServiceConfig = { + provider: 'aws', service: 'rds', enabled: true, term: 3, payment: 'all-upfront', coverage: 80, + include_engines: ['mysql', 'postgres'], + exclude_types: ['db.t3.micro'], + include_regions: ['us-east-1'], + min_count: 3, + }; + populateServiceFilterControls(target, svc); + expect((document.getElementById('aws-rds-include-engines') as HTMLInputElement).value).toBe('mysql, postgres'); + expect((document.getElementById('aws-rds-exclude-types') as HTMLInputElement).value).toBe('db.t3.micro'); + expect((document.getElementById('aws-rds-include-regions') as HTMLInputElement).value).toBe('us-east-1'); + expect((document.getElementById('aws-rds-min-count') as HTMLInputElement).value).toBe('3'); + }); + + it('clears inputs when no config (min-count -> 0)', () => { + populateServiceFilterControls(target, undefined); + expect((document.getElementById('aws-rds-include-engines') as HTMLInputElement).value).toBe(''); + expect((document.getElementById('aws-rds-min-count') as HTMLInputElement).value).toBe('0'); + }); + + it('reads inputs back into parsed values', () => { + (document.getElementById('aws-rds-include-engines') as HTMLInputElement).value = 'MySQL, mysql, postgres'; + (document.getElementById('aws-rds-min-count') as HTMLInputElement).value = '5'; + const v = readServiceFilterControls(target); + expect(isServiceFilterError(v)).toBe(false); + if (!isServiceFilterError(v)) { + expect(v.include_engines).toEqual(['mysql', 'postgres']); + expect(v.min_count).toBe(5); + expect(v.exclude_engines).toEqual([]); + } + }); + + it('treats blank min-count as 0', () => { + (document.getElementById('aws-rds-min-count') as HTMLInputElement).value = ' '; + const v = readServiceFilterControls(target); + expect(isServiceFilterError(v)).toBe(false); + if (!isServiceFilterError(v)) expect(v.min_count).toBe(0); + }); + + it('errors loudly on a fractional min-count', () => { + (document.getElementById('aws-rds-min-count') as HTMLInputElement).value = '1.5'; + const v = readServiceFilterControls(target); + expect(isServiceFilterError(v)).toBe(true); + if (isServiceFilterError(v)) expect(v.message).toContain('whole number'); + }); + + it('errors loudly on a negative min-count', () => { + (document.getElementById('aws-rds-min-count') as HTMLInputElement).value = '-2'; + const v = readServiceFilterControls(target); + expect(isServiceFilterError(v)).toBe(true); + }); +}); + +describe('read with no injected panel', () => { + it('returns empty lists and 0 min-count (no filters)', () => { + document.body.innerHTML = ''; + const v = readServiceFilterControls(target); + expect(isServiceFilterError(v)).toBe(false); + if (!isServiceFilterError(v)) { + expect(v.include_engines).toEqual([]); + expect(v.min_count).toBe(0); + } + }); +}); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 089903d58..a1956a9d2 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -303,6 +303,10 @@ export interface ServiceConfig { exclude_regions?: string[]; include_types?: string[]; exclude_types?: string[]; + /** Minimum instance/node count a recommendation must carry to be surfaced + * (GUI equivalent of the CLI --min-count flag). 0 / undefined disables the + * filter. */ + min_count?: number; } /** Response from GET /api/info (unauthenticated). Only safe-to-expose fields. */ diff --git a/frontend/src/serviceFilters.ts b/frontend/src/serviceFilters.ts new file mode 100644 index 000000000..58124ed3f --- /dev/null +++ b/frontend/src/serviceFilters.ts @@ -0,0 +1,231 @@ +/** + * Per-service recommendation-filter controls for the Settings → Service + * Defaults cards. + * + * Exposes the CLI filter knobs (include/exclude engines, instance types, + * regions, and the --min-count threshold) in the GUI. The controls are + * injected into each service card at runtime (keyed off SERVICE_FIELDS) so a + * single code path drives every card instead of ~20 hand-written HTML blocks, + * and so the inject / populate / read logic stays unit-testable in isolation. + * + * The backend already persists these fields on config.ServiceConfig and + * applies them server-side (engines/types/regions in + * scheduler.filterRecsByResolvedConfigs; min_count likewise). This module is + * purely the missing GUI surface. + * + * All DOM construction goes through createElement + .value assignment (never + * innerHTML), so user-entered values cannot inject markup. + */ + +import type { ServiceConfig } from './api/types'; + +/** Identifies a single service card. Subset of a SERVICE_FIELDS entry. */ +export interface ServiceFilterTarget { + provider: string; + service: string; + /** ID of the card's term