diff --git a/frontend/src/__tests__/html.test.ts b/frontend/src/__tests__/html.test.ts index 5fcc44999..668253945 100644 --- a/frontend/src/__tests__/html.test.ts +++ b/frontend/src/__tests__/html.test.ts @@ -669,6 +669,12 @@ describe('HTML Structure', () => { const notifyDays = document.getElementById('setting-notification-days') as HTMLInputElement | null; expect(notifyDays?.getAttribute('min')).toBe('1'); expect(notifyDays?.getAttribute('max')).toBe('30'); + + // #702: ramp-interval-days must carry max=365 so the browser spinner + // stays bounded and wireRangeInput uses the correct upper limit. + const intervalDays = document.getElementById('ramp-interval-days') as HTMLInputElement | null; + expect(intervalDays?.getAttribute('min')).toBe('1'); + expect(intervalDays?.getAttribute('max')).toBe('365'); }); test('password inputs have minlength', () => { diff --git a/frontend/src/__tests__/plans-range-validation.test.ts b/frontend/src/__tests__/plans-range-validation.test.ts new file mode 100644 index 000000000..7205c88e3 --- /dev/null +++ b/frontend/src/__tests__/plans-range-validation.test.ts @@ -0,0 +1,420 @@ +/** + * Live range + integer-only validation on the five plan-creation numeric + * inputs (issue #702). + * + * Covers wireRangeInput (via wirePlanRangeInputs) for: + * #plan-coverage min=0 max=100 + * #ramp-step-percent min=1 max=100 + * #ramp-interval-days min=1 max=365 (max attr was previously missing) + * #plan-notify-days min=1 max=30 + * + * Each case fires a synthetic `input` or `blur` event and asserts on + * aria-invalid + the sibling .field-error span. Scientific notation + * (1e+30) must also be rejected because parseInt('1e+30') === 1 + * but the digits-only regex ^\d+$ rejects the exponent form. + */ + +import { openNewPlanModal, closePlanModal } from '../plans'; +import type { ToastOptions } from '../toast'; + +jest.mock('../api', () => ({ + getPlans: jest.fn(), + getPlannedPurchases: jest.fn(), + getPlan: jest.fn(), + createPlan: jest.fn(), + updatePlan: jest.fn(), + patchPlan: jest.fn(), + deletePlan: jest.fn(), + runPlannedPurchase: jest.fn(), + pausePlannedPurchase: jest.fn(), + resumePlannedPurchase: jest.fn(), + deletePlannedPurchase: jest.fn(), + createPlannedPurchases: jest.fn(), + listPlanAccounts: jest.fn().mockResolvedValue([]), + setPlanAccounts: jest.fn().mockResolvedValue(undefined), + listAccounts: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../state', () => ({ + getRecommendations: jest.fn().mockReturnValue([]), + getSelectedRecommendationIDs: jest.fn().mockReturnValue(new Set()), + getVisibleRecommendations: jest.fn().mockReturnValue([]), + setVisibleRecommendations: jest.fn(), + getCurrentProvider: jest.fn().mockReturnValue(''), + setCurrentProvider: jest.fn(), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + setCurrentAccountIDs: jest.fn(), + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), + getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', role: 'admin' }), +})); + +jest.mock('../history', () => ({ viewPlanHistory: jest.fn() })); + +jest.mock('../commitmentOptions', () => ({ + populateTermSelect: jest.fn(), + populatePaymentSelect: jest.fn(), + isValidCombination: jest.fn().mockReturnValue(true), + normalizePaymentValue: jest.fn((value) => value), +})); + +const mockShowToast = jest.fn((_opts: ToastOptions) => ({ dismiss: jest.fn() })); +jest.mock('../toast', () => ({ + showToast: (opts: ToastOptions) => mockShowToast(opts), +})); + +jest.mock('../confirmDialog', () => ({ + confirmDialog: jest.fn(() => Promise.resolve(true)), +})); + +jest.mock('../utils', () => ({ + formatDate: jest.fn((v) => v ?? ''), + formatTerm: jest.fn((y) => `${y}yr`), + formatRampSchedule: jest.fn((v) => v ?? ''), + getStatusBadge: jest.fn(() => ({ class: 'active', label: 'Active' })), + escapeHtml: jest.fn((s) => s ?? ''), + formatCurrency: jest.fn((v) => `$${v}`), + populateAccountFilter: jest.fn(() => Promise.resolve()), +})); + +// --------------------------------------------------------------------------- +// DOM helpers +// --------------------------------------------------------------------------- + +function addInput( + parent: Element, + id: string, + type: string, + value: string, + attrs: Record = {}, +): HTMLInputElement { + const el = document.createElement('input'); + el.id = id; + el.type = type; + el.value = value; + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); + parent.appendChild(el); + return el; +} + +function addSelect( + parent: Element, + id: string, + options: Array<{ value: string; label: string }>, +): HTMLSelectElement { + const el = document.createElement('select'); + el.id = id; + for (const { value, label } of options) { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + el.appendChild(opt); + } + parent.appendChild(el); + return el; +} + +function buildPlanModalDOM(): void { + // Outer modal container + const modal = document.createElement('div'); + modal.id = 'plan-modal'; + + const title = document.createElement('h3'); + title.id = 'plan-modal-title'; + title.textContent = 'New Purchase Plan'; + modal.appendChild(title); + + const form = document.createElement('form'); + form.id = 'plan-form'; + modal.appendChild(form); + + addInput(form, 'plan-id', 'hidden', ''); + addInput(form, 'plan-name', 'text', ''); + + const desc = document.createElement('textarea'); + desc.id = 'plan-description'; + form.appendChild(desc); + + // Provider select with optgroups for service + addSelect(form, 'plan-provider', [ + { value: 'aws', label: 'AWS' }, + { value: 'azure', label: 'Azure' }, + { value: 'gcp', label: 'GCP' }, + ]); + + const svcSelect = document.createElement('select'); + svcSelect.id = 'plan-service'; + const awsGroup = document.createElement('optgroup'); + awsGroup.label = 'AWS Services'; + const ec2Opt = document.createElement('option'); + ec2Opt.value = 'ec2'; + ec2Opt.textContent = 'EC2'; + awsGroup.appendChild(ec2Opt); + svcSelect.appendChild(awsGroup); + form.appendChild(svcSelect); + + addSelect(form, 'plan-term', [ + { value: '1', label: '1 Year' }, + { value: '3', label: '3 Years' }, + ]); + addSelect(form, 'plan-payment', [ + { value: 'no-upfront', label: 'No Upfront' }, + { value: 'partial-upfront', label: 'Partial Upfront' }, + { value: 'all-upfront', label: 'All Upfront' }, + ]); + + // The five numeric inputs under test (min/max match HTML spec + issue #702) + addInput(form, 'plan-coverage', 'number', '80', { min: '0', max: '100' }); + + const autoPurchase = document.createElement('input'); + autoPurchase.id = 'plan-auto-purchase'; + autoPurchase.type = 'checkbox'; + form.appendChild(autoPurchase); + + addInput(form, 'plan-notify-days', 'number', '3', { min: '1', max: '30' }); + + const enabledCheck = document.createElement('input'); + enabledCheck.id = 'plan-enabled'; + enabledCheck.type = 'checkbox'; + enabledCheck.checked = true; + form.appendChild(enabledCheck); + + // Ramp radio buttons + for (const [value, checked] of [['immediate', true], ['weekly-25pct', false], ['monthly-10pct', false], ['custom', false]] as const) { + const r = document.createElement('input'); + r.type = 'radio'; + r.name = 'ramp-schedule'; + r.value = value; + if (checked) r.checked = true; + form.appendChild(r); + } + + // Custom ramp config section + const customConfig = document.createElement('div'); + customConfig.id = 'custom-ramp-config'; + customConfig.className = 'hidden'; + addInput(customConfig, 'ramp-step-percent', 'number', '20', { min: '1', max: '100' }); + addInput(customConfig, 'ramp-interval-days', 'number', '7', { min: '1', max: '365' }); + form.appendChild(customConfig); + + // Plans list and planned purchases (required by plans.ts loadPlans path) + const plansList = document.createElement('div'); + plansList.id = 'plans-list'; + const ppList = document.createElement('div'); + ppList.id = 'planned-purchases-list'; + + document.body.replaceChildren(modal, plansList, ppList); +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function fire(el: HTMLElement, type: 'input' | 'blur'): void { + el.dispatchEvent(new Event(type)); +} + +function errorEl(inputId: string): HTMLElement | null { + return document.getElementById(`${inputId}-range-error`); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('plan numeric inputs: live range validation (#702)', () => { + beforeEach(() => { + jest.clearAllMocks(); + buildPlanModalDOM(); + // openNewPlanModal calls wirePlanRangeInputs which wires all five inputs. + openNewPlanModal(); + }); + + // --- out-of-range values show inline error -------------------------------- + + it.each([ + ['plan-coverage', '101', 'Must be a whole number between 0 and 100'], + ['plan-coverage', '-1', 'Must be a whole number between 0 and 100'], + ['ramp-step-percent', '0', 'Must be a whole number between 1 and 100'], + ['ramp-step-percent', '101', 'Must be a whole number between 1 and 100'], + ['ramp-interval-days', '0', 'Must be a whole number between 1 and 365'], + ['ramp-interval-days', '366', 'Must be a whole number between 1 and 365'], + ['plan-notify-days', '0', 'Must be a whole number between 1 and 30'], + ['plan-notify-days', '31', 'Must be a whole number between 1 and 30'], + ])('typing %s=%s shows inline error "%s"', (inputId, value, expectedMsg) => { + const input = document.getElementById(inputId) as HTMLInputElement; + input.value = value; + fire(input, 'input'); + + expect(input.getAttribute('aria-invalid')).toBe('true'); + const err = errorEl(inputId); + expect(err).not.toBeNull(); + expect(err!.classList.contains('hidden')).toBe(false); + expect(err!.textContent).toBe(expectedMsg); + }); + + // --- scientific notation rejected ---------------------------------------- + + it('rejects scientific notation (1e+30) in ramp-interval-days', () => { + const input = document.getElementById('ramp-interval-days') as HTMLInputElement; + input.value = '1e+30'; + fire(input, 'input'); + + expect(input.getAttribute('aria-invalid')).toBe('true'); + const err = errorEl('ramp-interval-days'); + expect(err).not.toBeNull(); + expect(err!.classList.contains('hidden')).toBe(false); + }); + + it('rejects scientific notation (1e+30) in plan-coverage', () => { + const input = document.getElementById('plan-coverage') as HTMLInputElement; + input.value = '1e+30'; + fire(input, 'input'); + + expect(input.getAttribute('aria-invalid')).toBe('true'); + }); + + it('rejects scientific notation (2e2) in ramp-step-percent', () => { + const input = document.getElementById('ramp-step-percent') as HTMLInputElement; + input.value = '2e2'; + fire(input, 'input'); + + expect(input.getAttribute('aria-invalid')).toBe('true'); + }); + + // --- valid values clear the error ---------------------------------------- + + it.each([ + ['plan-coverage', '80'], + ['plan-coverage', '0'], + ['plan-coverage', '100'], + ['ramp-step-percent', '25'], + ['ramp-interval-days', '7'], + ['ramp-interval-days', '365'], + ['ramp-interval-days', '1'], + ['plan-notify-days', '3'], + ['plan-notify-days', '1'], + ['plan-notify-days', '30'], + ])('valid value %s=%s clears aria-invalid and hides the error span', (inputId, value) => { + const input = document.getElementById(inputId) as HTMLInputElement; + // First trigger an error state. + input.value = '9999'; + fire(input, 'input'); + expect(input.getAttribute('aria-invalid')).toBe('true'); + + // Then type a valid value. + input.value = value; + fire(input, 'input'); + expect(input.getAttribute('aria-invalid')).toBeNull(); + const err = errorEl(inputId); + expect(err).not.toBeNull(); + expect(err!.classList.contains('hidden')).toBe(true); + }); + + // --- empty field clears the error ---------------------------------------- + + it('clearing the field removes aria-invalid and hides the error', () => { + const input = document.getElementById('plan-notify-days') as HTMLInputElement; + input.value = '999'; + fire(input, 'input'); + expect(input.getAttribute('aria-invalid')).toBe('true'); + + input.value = ''; + fire(input, 'input'); + expect(input.getAttribute('aria-invalid')).toBeNull(); + expect(errorEl('plan-notify-days')!.classList.contains('hidden')).toBe(true); + }); + + // --- blur clamps the value to [min, max] --------------------------------- + + it('out-of-range value is clamped to max on blur', () => { + const input = document.getElementById('plan-coverage') as HTMLInputElement; + input.value = '150'; + fire(input, 'blur'); + + expect(input.value).toBe('100'); + expect(input.getAttribute('aria-invalid')).toBeNull(); + expect(errorEl('plan-coverage')!.classList.contains('hidden')).toBe(true); + }); + + it('below-min value is clamped to min on blur', () => { + const input = document.getElementById('plan-notify-days') as HTMLInputElement; + input.value = '0'; + fire(input, 'blur'); + + expect(input.value).toBe('1'); + expect(input.getAttribute('aria-invalid')).toBeNull(); + }); + + it('non-integer value is NOT clamped on blur (error stays visible)', () => { + const input = document.getElementById('plan-coverage') as HTMLInputElement; + input.value = '1e+30'; + fire(input, 'input'); + expect(input.getAttribute('aria-invalid')).toBe('true'); + + fire(input, 'blur'); + // Non-integer cannot be clamped; error persists so the user sees feedback. + expect(input.value).toBe('1e+30'); + expect(input.getAttribute('aria-invalid')).toBe('true'); + }); + + // --- idempotency: stale error UI is reconciled on reopen ----------------- + + it('re-opening the modal reconciles stale error UI for valid default values', () => { + // Trigger an error. + const coverage = document.getElementById('plan-coverage') as HTMLInputElement; + coverage.value = '999'; + fire(coverage, 'input'); + expect(coverage.getAttribute('aria-invalid')).toBe('true'); + + // Reset the field to a valid value (simulating form reset) then reopen. + coverage.value = '80'; + closePlanModal(); + openNewPlanModal(); + + // The reopen re-dispatches 'input', which clears the stale error. + expect(coverage.getAttribute('aria-invalid')).toBeNull(); + expect(errorEl('plan-coverage')!.classList.contains('hidden')).toBe(true); + }); + + // --- idempotency: re-opening does not stack duplicate error spans --------- + + it('re-opening the modal does not create duplicate error spans', () => { + // Trigger validation to create the error spans. + const coverage = document.getElementById('plan-coverage') as HTMLInputElement; + coverage.value = '999'; + fire(coverage, 'input'); + + // Close and re-open. + closePlanModal(); + openNewPlanModal(); + + // Exactly one error span per input — no duplicates. + expect(document.querySelectorAll('#plan-coverage-range-error')).toHaveLength(1); + expect(document.querySelectorAll('#ramp-interval-days-range-error')).toHaveLength(1); + expect(document.querySelectorAll('#plan-notify-days-range-error')).toHaveLength(1); + expect(document.querySelectorAll('#ramp-step-percent-range-error')).toHaveLength(1); + }); + + // --- aria attributes are set correctly ------------------------------------ + + it('error span has role=status and aria-live=polite', () => { + const input = document.getElementById('plan-coverage') as HTMLInputElement; + input.value = '999'; + fire(input, 'input'); + + const err = errorEl('plan-coverage'); + expect(err).not.toBeNull(); + expect(err!.getAttribute('role')).toBe('status'); + expect(err!.getAttribute('aria-live')).toBe('polite'); + }); + + it('input aria-describedby references the error span id', () => { + const input = document.getElementById('plan-coverage') as HTMLInputElement; + input.value = '999'; + fire(input, 'input'); + + const describedBy = input.getAttribute('aria-describedby') ?? ''; + expect(describedBy).toContain('plan-coverage-range-error'); + }); +}); diff --git a/frontend/src/index.html b/frontend/src/index.html index ade5fa6fb..181d5b036 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -827,7 +827,7 @@

Ramp-up Schedule

diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index e8c40e9c1..e6a0abe03 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -547,6 +547,8 @@ async function editPlan(planId: string): Promise { } void setupPlanAccountsSection(backendPlan.id); + // Wire live range validation on all five numeric inputs (#702). + wirePlanRangeInputs(); const planModal = document.getElementById('plan-modal'); if (planModal) openModal(planModal); } catch (error) { @@ -790,6 +792,9 @@ export function openCreatePlanModal(snapshot?: readonly api.Recommendation[]): v // Set up ramp schedule change handlers for dynamic plan name setupRampScheduleHandlers(); + // Wire live range validation on all five numeric inputs (#702). + wirePlanRangeInputs(); + // Generate initial plan name updatePlanNameFromSchedule(); @@ -819,6 +824,9 @@ export function openNewPlanModal(): void { // Set up ramp schedule change handlers for dynamic plan name setupRampScheduleHandlers(); + // Wire live range validation on all five numeric inputs (#702). + wirePlanRangeInputs(); + // Generate initial plan name updatePlanNameFromSchedule(); @@ -884,6 +892,106 @@ function updatePlanNameFromSchedule(): void { } } +/** + * Wire live range + integer-only validation on a plan numeric input. + * + * Registers `input` and `blur` event handlers that: + * - reject non-integer values via the regex `^\d+$` (blocks scientific + * notation such as `1e+30` and decimal fractions) + * - show a sibling `.field-error` span when the value is out of [min, max] + * or non-integer, and hide it when the value is valid or the field is empty + * - set / clear `aria-invalid` for screen-reader accessibility + * + * Idempotent: the `data-range-wired` attribute guards against re-registering + * duplicate listeners when the modal is closed and reopened. The error span + * is created once on the first call and reused thereafter. Explicit `min` / + * `max` parameters are used instead of reading HTML attributes so callers are + * the source of truth and the helper stays self-contained. + */ +function wireRangeInput(inputId: string, min: number, max: number): void { + const input = document.getElementById(inputId) as HTMLInputElement | null; + if (!input) return; + + // Idempotency guard: skip registration if already wired during a prior + // modal open. The error span, aria-describedby, and listeners persist + // across opens because the modal node stays in the DOM. + if (input.dataset['rangeWired']) { + // Re-trigger validation so stale error UI is reconciled on modal reopen. + input.dispatchEvent(new Event('input')); + return; + } + input.dataset['rangeWired'] = '1'; + + const errorId = `${inputId}-range-error`; + let errorEl = document.getElementById(errorId); + if (!errorEl) { + errorEl = document.createElement('small'); + errorEl.id = errorId; + errorEl.className = 'field-error hidden'; + errorEl.setAttribute('role', 'status'); + errorEl.setAttribute('aria-live', 'polite'); + input.insertAdjacentElement('afterend', errorEl); + const existing = input.getAttribute('aria-describedby'); + input.setAttribute( + 'aria-describedby', + existing ? `${existing} ${errorId}` : errorId, + ); + } + const error = errorEl; + const message = `Must be a whole number between ${min} and ${max}`; + const integerPattern = /^\d+$/; + + const check = (): void => { + const raw = input.value.trim(); + if (raw === '') { + input.removeAttribute('aria-invalid'); + error.classList.add('hidden'); + return; + } + if (!integerPattern.test(raw)) { + input.setAttribute('aria-invalid', 'true'); + error.textContent = message; + error.classList.remove('hidden'); + return; + } + const parsed = parseInt(raw, 10); + if (parsed < min || parsed > max) { + input.setAttribute('aria-invalid', 'true'); + error.textContent = message; + error.classList.remove('hidden'); + } else { + input.removeAttribute('aria-invalid'); + error.classList.add('hidden'); + } + }; + + const clampOnBlur = (): void => { + const raw = input.value.trim(); + if (!integerPattern.test(raw) || raw === '') return; + const parsed = parseInt(raw, 10); + if (parsed < min || parsed > max) { + input.value = String(Math.min(max, Math.max(min, parsed))); + input.removeAttribute('aria-invalid'); + error.classList.add('hidden'); + } + }; + + input.addEventListener('input', check); + input.addEventListener('blur', clampOnBlur); +} + +/** + * Wire live range validation on all five plan-creation number inputs. + * Called every time the plan modal opens so validation is active for both + * the create and edit flows. + */ +function wirePlanRangeInputs(): void { + wireRangeInput('plan-coverage', 0, 100); + wireRangeInput('ramp-step-percent', 1, 100); + wireRangeInput('ramp-interval-days', 1, 365); + wireRangeInput('plan-notify-days', 1, 30); +} + /** * Set up event handlers for ramp schedule changes */