diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 42b6ff5c7..963e2f302 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -3434,4 +3434,106 @@ describe('Plans Module', () => { expect(badge?.getAttribute('title')).toContain(maliciousNote); }); }); + describe('inline schedule preview', () => { + const restartNotice = 'restarts from today for a plan already in progress; executed purchases are not shown'; + const host = (): HTMLElement => document.getElementById('plan-editor-panel')!; + const points = (): number => host().querySelectorAll('[data-preview-step]').length; + const backendPlan = (ramp: Record, coverage: number | null = 80) => ({ + id: 'plan-1', name: 'Plan', enabled: true, auto_purchase: false, notification_days_before: 3, + services: { ec2: { provider: 'aws', service: 'ec2', term: 1, payment: 'all-upfront', ...(coverage === null ? {} : { coverage }) } }, + ramp_schedule: ramp, + }); + const openEdit = async (plan: unknown): Promise => { + (api.getPlans as jest.Mock).mockResolvedValue({ plans: [{ id: 'plan-1', name: 'Plan', enabled: true, auto_purchase: false, services: {} }] }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + (api.getPlan as jest.Mock).mockResolvedValue(plan); + await loadPlans(); + expect(points()).toBe(0); + (document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement).click(); + await jest.advanceTimersByTimeAsync(50); + }; + beforeEach(() => { + jest.useFakeTimers({ now: new Date('2026-10-15T12:00:00Z') }); + const form = document.getElementById('plan-form')!.innerHTML; + document.body.innerHTML = `
+ `; + _resetRampHandlersForTest(); + }); + afterEach(() => { closePlanModal(); jest.useRealTimers(); }); + + test.each([ + ['weekly 25%', { type: 'weekly', percent_per_step: 25, step_interval_days: 7 }, 4], + ['custom 33% / 14 days', { type: 'custom', percent_per_step: 33, step_interval_days: 14 }, 3], + ['monthly 10%', { type: 'monthly', percent_per_step: 10, step_interval_days: 30 }, 10], + ])('Edit shows the %s schedule preview and the restart notice', async (_name, ramp, count) => { + await openEdit(backendPlan(ramp)); + expect(points()).toBe(count); + expect(host().hidden).toBe(false); + expect(host().querySelector('.editor-draft-notice')!.textContent).toContain(restartNotice); + expect(api.createPlan).not.toHaveBeenCalled(); expect(api.updatePlan).not.toHaveBeenCalled(); + expect(api.createPlannedPurchases).not.toHaveBeenCalled(); + }); + + test('weekly 20% plan currently prefills as immediate and previews 1 step (#1156)', async () => { + // Documents current behaviour of the Edit prefill, which maps weekly != 25% to `immediate`; see #1156. + // Invert this expectation (weekly 20% should preview 5 steps) when #1156 is fixed. + await openEdit(backendPlan({ type: 'weekly', percent_per_step: 20, step_interval_days: 7 })); + expect(points()).toBe(1); + }); + + test('Edit with absent coverage shows the validation message and no points', async () => { + await openEdit(backendPlan({ type: 'weekly', percent_per_step: 25, step_interval_days: 7 }, null)); + expect(points()).toBe(0); + expect(host().querySelector('[data-editor="summary"]')!.textContent).not.toBe(''); + }); + + // Panel and modal listeners on the form (input/change bubble, invalid/submit capture), keyed by type:capture. + const trackFormListeners = () => { + const form = document.getElementById('plan-form')!; + const owned = new Set(['input:false', 'change:false', 'invalid:true', 'submit:true']); + const key = (type: string, o?: boolean | AddEventListenerOptions): string => `${type}:${o === true || (typeof o === 'object' && o.capture === true)}`; + const entries: { key: string; fn: unknown }[] = []; + const add = form.addEventListener.bind(form), remove = form.removeEventListener.bind(form); + jest.spyOn(form, 'addEventListener').mockImplementation((type: string, fn: EventListenerOrEventListenerObject, o?: boolean | AddEventListenerOptions) => { + if (owned.has(key(type, o))) entries.push({ key: key(type, o), fn }); + add(type, fn, o); + }); + jest.spyOn(form, 'removeEventListener').mockImplementation((type: string, fn: EventListenerOrEventListenerObject, o?: boolean | AddEventListenerOptions) => { + const index = entries.findIndex(entry => entry.key === key(type, o) && entry.fn === fn); + if (index >= 0) entries.splice(index, 1); + remove(type, fn, o); + }); + return entries; + }; + + test('closing the Edit modal removes every listener the panel and the modal added', async () => { + const entries = trackFormListeners(); + await openEdit(backendPlan({ type: 'weekly', percent_per_step: 25, step_interval_days: 7 })); + expect(entries.map(entry => entry.key).sort()).toEqual(['change:false', 'input:false', 'invalid:true', 'submit:true']); + closePlanModal(); + expect(entries).toEqual([]); + expect(host().hidden).toBe(true); expect(host().childElementCount).toBe(0); + expect(host().closest('.plan-editor-layout')!.classList.contains('has-editor-draft')).toBe(false); + }); + + test('Edit then Create without closing leaves exactly one panel listening and no Edit notice', async () => { + const entries = trackFormListeners(); + await openEdit(backendPlan({ type: 'weekly', percent_per_step: 25, step_interval_days: 7 })); + openCreatePlanModal(); + expect(entries.map(entry => entry.key).sort()).toEqual(['change:false', 'input:false', 'invalid:true', 'submit:true']); + expect(host().querySelector('.editor-draft-notice')!.textContent).not.toContain('restarts from today'); + const coverage = document.getElementById('plan-coverage') as HTMLInputElement; + coverage.value = '60'; coverage.dispatchEvent(new Event('input', { bubbles: true })); + expect(host().querySelector('[data-editor="summary"]')!.textContent).toContain('Target 60%'); + }); + + test('Create previews without the restart notice', () => { + openCreatePlanModal(); + expect(host().hidden).toBe(false); + expect(host().querySelector('.editor-draft-notice')!.textContent).not.toContain('restarts from today'); + }); + }); }); diff --git a/frontend/src/index.html b/frontend/src/index.html index da2843aa7..b5ac42f2a 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -820,7 +820,6 @@

User Management