diff --git a/frontend/src/__tests__/plan-editor-panel.test.ts b/frontend/src/__tests__/plan-editor-panel.test.ts index e8176fcd..f8aec3d0 100644 --- a/frontend/src/__tests__/plan-editor-panel.test.ts +++ b/frontend/src/__tests__/plan-editor-panel.test.ts @@ -169,3 +169,93 @@ describe('display-only default (Create dates are not saved yet)', () => { expect(host.querySelector('[data-editor="changes"] summary')!.textContent).toBe('Local date changes (0)'); }); }); + +describe('editable date gestures', () => { + const step = (index: number): SVGGElement => host.querySelector(`[data-preview-step="${index}"]`)!; + const harness = () => { + const svg = host.querySelector('svg')!; + let captured = false; + const release = jest.fn(() => { captured = false; }); + Object.assign(svg, { getScreenCTM: () => ({ inverse: () => ({}) }), + createSVGPoint: () => ({ x: 0, y: 0, matrixTransform() { return { x: this.x }; } }), + setPointerCapture: () => { captured = true; }, hasPointerCapture: () => captured, releasePointerCapture: release }); + const send = (type: string, target: Element, x: number, id = 1, primary = true): void => { + target.dispatchEvent(Object.assign(new Event(type, { bubbles: true, cancelable: true }), + { pointerId: id, isPrimary: primary, button: 0, clientX: x, clientY: 0 })); + }; + return { svg, send, release, day: 544 / 22, x: (index: number): number => Number(step(index).querySelector('circle')!.getAttribute('cx')) }; + }; + const changes = (): string => host.querySelector('[data-editor="changes"] summary')!.textContent!; + + test.each(['pointercancel', 'lostpointercapture', 'Escape'])('rolls back a captured draft on %s', reason => { + const { svg, send, release, day, x } = harness(); + send('pointerdown', step(1), x(1)); send('pointermove', svg, x(1) + day); + expect(field('date').value).toBe('2026-10-28'); expect(changes()).toContain('(1)'); + const key = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + const parentKey = jest.fn(); host.closest('.modal')!.addEventListener('keydown', parentKey); + if (reason === 'Escape') step(1).dispatchEvent(key); else send(reason, svg, x(1) + day); + expect(field('date').value).toBe('2026-10-27'); expect(changes()).toContain('(0)'); + expect(release).toHaveBeenCalled(); expect(form.inert).toBeFalsy(); + if (reason === 'Escape') { expect(key.defaultPrevented).toBe(true); expect(parentKey).not.toHaveBeenCalled(); } + const later = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + step(1).dispatchEvent(later); expect(later.defaultPrevented).toBe(false); + }); + + test('redistributes endpoint gestures, focuses the handle before inerting inputs and activates by keyboard', () => { + const { svg, send, day } = harness(); + const handle = host.querySelector('[data-preview-boundary="end"]')!; + const origin = Number(handle.querySelector('.preview-handle-marker')!.getAttribute('x')) + 6; + field('end').focus(); send('pointerdown', handle, origin); + expect(document.activeElement).toBe(handle); expect(field('end').disabled).toBe(true); + send('pointermove', svg, origin + day * 3); + expect(field('end').value).toBe('2026-11-06'); expect(step(1).getAttribute('aria-label')).toContain('2026-10-29'); + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + expect(field('end').value).toBe('2026-11-06'); + send('pointerup', svg, origin + day * 3); expect(field('end').disabled).toBe(false); + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(field('end').value).toBe('2026-11-07'); + handle.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })); + expect(document.activeElement).toBe(field('end')); + }); + + test('rejects collisions and outside release, and ignores other pointers', () => { + const { svg, send, day, x } = harness(); + send('pointerdown', svg, 100); expect(form.inert).toBeFalsy(); + send('pointerdown', step(1), x(1), 2, false); expect(form.inert).toBeFalsy(); + send('pointerdown', step(1), x(1)); expect(form.inert).toBe(true); + send('pointermove', svg, x(1) + day, 2); expect(field('date').value).toBe('2026-10-27'); + send('pointermove', svg, x(1) + day * 7); + expect(field('date').value).toBe('2026-10-27'); expect(field('error').textContent).toMatch(/order/); + send('pointerup', svg, 1000); + expect(field('error').textContent).toMatch(/plot/i); expect(field('date').value).toBe('2026-10-27'); expect(form.inert).toBeFalsy(); + }); + + test('selects points by click and arrow keys', () => { + step(2).dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(field('step').value).toBe('2'); expect(field('date').value).toBe('2026-11-03'); + step(2).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + expect(field('step').value).toBe('1'); expect(document.activeElement).toBe(step(1)); + step(1).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(field('step').value).toBe('2'); expect(field('date').value).toBe('2026-11-03'); + }); + + test('rechecks midnight on release and rolls back exactly with a reopen warning', () => { + const { svg, send, day, x } = harness(); + send('pointerdown', step(1), x(1)); send('pointermove', svg, x(1) + day); + expect(field('date').value).toBe('2026-10-28'); + jest.setSystemTime(new Date('2026-10-20T00:00:00Z')); + send('pointerup', svg, x(1) + day); + expect(field('date').value).toBe('2026-10-27'); expect(field('error').textContent).toMatch(/reopen/i); + expect(field('date').disabled).toBe(false); expect(form.inert).toBeFalsy(); + }); + + test('reports capture failure and leaves dates and date-input access intact', () => { + const { svg, send, x } = harness(); + Object.assign(svg, { setPointerCapture: () => { throw new Error('Capture unavailable'); } }); + const hit = step(1).querySelector('.preview-touch-target')!; + expect(hit.namespaceURI).toBe('http://www.w3.org/1999/xhtml'); expect(hit.tabIndex).toBe(-1); + send('pointerdown', hit, x(1)); + expect(field('date').value).toBe('2026-10-27'); expect(field('date').disabled).toBe(false); + expect(form.inert).toBeFalsy(); expect(field('error').textContent).toContain('Use date inputs'); + }); +}); diff --git a/frontend/src/__tests__/plan-schedule-plot.test.ts b/frontend/src/__tests__/plan-schedule-plot.test.ts new file mode 100644 index 00000000..a345d712 --- /dev/null +++ b/frontend/src/__tests__/plan-schedule-plot.test.ts @@ -0,0 +1,39 @@ +import { renderSchedulePlot, updateSchedulePlot, schedulePlotFrame, schedulePlotX } from '../plan-schedule-plot'; + +test('plots logical ordinals without bridging missing dates or relabeling retained steps', () => { + const points = [{ step: 2, date: '2026-10-22', target: 33 }, { step: 4, date: '2026-10-29', target: 99 }]; + const frame = schedulePlotFrame(points.map(point => point.date)); + const host = document.createElement('div'); + host.innerHTML = renderSchedulePlot(points, frame, 3, false); + const svg = host.querySelector('svg')!; + expect(svg.getAttribute('role')).toBe('img'); + expect(svg.querySelector('[data-preview-step="1"]')?.getAttribute('aria-label')).toContain('Step 2,'); + expect(svg.querySelector('[data-preview-step="3"]')?.hasAttribute('data-selected')).toBe(true); + expect(svg.querySelector('[data-preview-step="0"]')).toBeNull(); + expect(svg.querySelector('.preview-staircase')?.getAttribute('d')).toBe(` M ${schedulePlotX(points[0]!.date, frame)} ${260 - 33 * 2.2} M ${schedulePlotX(points[1]!.date, frame)} ${260 - 99 * 2.2}`); + expect(svg.querySelector('[role="button"]')).toBeNull(); + const retained = svg.querySelector('[data-preview-step="3"]'); + const updated = [points[0]!, { ...points[1]!, step: 3, target: 66 }]; + updateSchedulePlot(svg, updated, frame, 2, false); + expect(svg.querySelector('[data-preview-step="2"]')).toBe(retained); + expect(retained?.getAttribute('aria-label')).toContain('Step 3,'); + expect(svg.querySelector('.preview-staircase')?.getAttribute('d')).toBe(` M ${schedulePlotX(points[0]!.date, frame)} ${260 - 33 * 2.2} H ${schedulePlotX(points[1]!.date, frame)} V ${260 - 66 * 2.2}`); +}); + +test('sizes the plot frame from the point count and rejects an empty schedule', () => { + const dates = ['2026-10-22', '2026-10-29']; + const frame = schedulePlotFrame(dates); + expect(schedulePlotFrame([...dates].reverse())).toEqual(frame); + expect(() => schedulePlotFrame([])).toThrow('No dated steps'); + expect(schedulePlotFrame(['2026-10-22']).span).toBe(8); + expect(schedulePlotFrame(Array.from({ length: 5 }, () => '2026-10-22')).width).toBe(640); + expect(schedulePlotFrame(Array.from({ length: 100 }, () => '2026-10-22')).width).toBe(4896); +}); + +test('starts the staircase at zero for contiguous steps from step 1', () => { + const points = [{ step: 1, date: '2026-10-22', target: 25 }, { step: 2, date: '2026-10-29', target: 50 }]; + const frame = schedulePlotFrame(points.map(point => point.date)); + const host = document.createElement('div'); + host.innerHTML = renderSchedulePlot(points, frame, 0, false); + expect(host.querySelector('.preview-staircase')?.getAttribute('d')).toBe(`M 48 260 H ${schedulePlotX(points[0]!.date, frame)} V 205 H ${schedulePlotX(points[1]!.date, frame)} V 150`); +}); diff --git a/frontend/src/__tests__/plan-schedule-preview.test.ts b/frontend/src/__tests__/plan-schedule-preview.test.ts deleted file mode 100644 index 1822be78..00000000 --- a/frontend/src/__tests__/plan-schedule-preview.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { openPlanSchedulePreview, PlanSchedulePreviewInput } from '../plan-schedule-preview'; -import { closeModal } from '../modal'; -import { suggestedPurchaseStartDate } from '../lib/calendar-date'; -import { renderSchedulePlot, updateSchedulePlot, schedulePlotFrame, schedulePlotX } from '../plan-schedule-plot'; - -const draft: PlanSchedulePreviewInput = { name: 'Compute ramp', preset: 'weekly-25pct', coverage: '80', stepPercent: '25', intervalDays: '7', notificationDays: '3', term: '1', payment: 'monthly' }; -let parent: HTMLElement; -beforeEach(() => { - jest.useFakeTimers({ now: new Date('2026-10-15T23:59:00Z') }); - document.body.innerHTML = '
'; - parent = document.getElementById('plan-modal')!; - document.getElementById('trigger')!.focus(); -}); -afterEach(() => { - const preview = document.getElementById('plan-schedule-preview'); - if (preview) closeModal(preview); - jest.useRealTimers(); -}); - -test('shows truthful hypothetical dates, preserves parent values, and restores focus on Escape', () => { - openPlanSchedulePreview(draft, parent); - const preview = document.getElementById('plan-schedule-preview')!; - expect(preview.textContent).toContain('These dates are not saved or scheduled'); - expect(preview.textContent).toContain('Planned cumulative target (%)'); - expect(preview.textContent).toContain('Amount unavailable'); - expect(preview.querySelectorAll('[data-preview-step]')).toHaveLength(4); - expect(parent.inert).toBe(true); - const point = preview.querySelector('[data-preview-step="3"]') as unknown as HTMLElement; - point.dispatchEvent(new Event('click', { bubbles: true })); - expect((document.getElementById('preview-selected-date') as HTMLInputElement).value).toBe('2026-11-10'); - point.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); - expect(document.getElementById('preview-selected-step')?.textContent).toBe('Step 3'); - expect(preview.querySelector('aside details')?.open).toBe(false); - preview.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - expect(document.getElementById('plan-schedule-preview')).toBeNull(); - expect(parent.inert).toBeFalsy(); - expect(parent.querySelector('input')?.value).toBe('unchanged'); - expect(document.activeElement?.id).toBe('trigger'); -}); - -test('preserves legacy custom33 shares and safely renders text when reopened', () => { - openPlanSchedulePreview({ ...draft, preset: 'custom', stepPercent: '33', name: '' }, parent); - expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(3); - expect(document.querySelector('[data-preview-step="2"]')?.getAttribute('aria-label')).toContain('79.2%'); - expect(document.querySelector('img')).toBeNull(); - openPlanSchedulePreview({ ...draft, coverage: '0', preset: 'immediate' }, parent); - expect(document.querySelectorAll('#plan-schedule-preview')).toHaveLength(1); - expect(document.querySelector('[data-preview-step]')?.getAttribute('aria-label')).toContain('target 0%'); - openPlanSchedulePreview({ ...draft, preset: 'custom', stepPercent: '1' }, parent); - expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(100); -}); - -test.each([ - { coverage: '' }, { coverage: '101' }, { coverage: '1.5' }, { notificationDays: '0' }, { notificationDays: 'Infinity' }, - { preset: 'toString' }, { preset: 'custom', stepPercent: '0' }, { preset: 'custom', intervalDays: '366' }, -])('rejects invalid draft %j without hiding the form', change => { - expect(() => openPlanSchedulePreview({ ...draft, ...change }, parent)).toThrow(); - expect(document.getElementById('plan-schedule-preview')).toBeNull(); - expect(parent.inert).toBeFalsy(); -}); - -test('uses UTC calendar dates across a year boundary for both real callers', () => { - expect(suggestedPurchaseStartDate(3, new Date('2026-12-31T23:59:00Z'))).toBe('2027-01-05'); -}); - -const field = (id: string): HTMLInputElement => document.getElementById(id) as HTMLInputElement; -const changeDate = (id: string, value: string): void => { - const input = field(id); - input.focus(); input.value = value; - input.dispatchEvent(new Event('input', { bubbles: true })); - input.dispatchEvent(new Event('change', { bubbles: true })); -}; -const point = (index: number): SVGGElement => document.querySelector(`[data-preview-step="${index}"]`)!; - -test('edits actual draft dates, guards collisions and preserves nodes, shares and parent values', () => { - openPlanSchedulePreview(draft, parent); - const input = field('preview-selected-date'), first = point(0); - expect(input.readOnly).toBe(false); - changeDate(input.id, '2026-10-21'); - expect(first.getAttribute('aria-label')).toContain('2026-10-21, target 20%'); - expect(point(0)).toBe(first); - first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', altKey: true, bubbles: true })); - expect(input.value).toBe('2026-10-22'); - expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(4); - changeDate(input.id, '2026-10-27'); - expect(first.getAttribute('aria-label')).toContain('2026-10-22'); - expect(input.value).toBe('2026-10-27'); - expect(field('preview-range-start').disabled).toBe(true); - point(2).dispatchEvent(new Event('click', { bubbles: true })); - expect(field(input.id)).toBe(input); - expect(document.activeElement).toBe(input); - expect(document.getElementById('preview-selected-step')?.textContent).toBe('Step 1'); - expect(document.getElementById('preview-date-error')?.textContent).toMatch(/order/); - field('preview-reset-dates').click(); - expect(input.value).toBe('2026-10-20'); - expect(document.getElementById('preview-date-error')?.textContent).toBe(''); - expect(parent.querySelector('input')?.value).toBe('unchanged'); - expect(document.body.textContent).toContain('Save Plan does not apply these date edits'); -}); - -test('retains partial paired inputs for correction and shows initial-to-draft differences', () => { - openPlanSchedulePreview(draft, parent); - const start = field('preview-range-start'), end = field('preview-range-end'); - start.focus(); start.value = ''; start.dispatchEvent(new Event('input')); - point(0).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); - expect(start.value).toBe(''); - expect(document.activeElement).toBe(start); - changeDate(start.id, '2026-11-20'); - expect(start.value).toBe('2026-11-20'); - expect(end.disabled).toBe(false); - expect(point(0).getAttribute('aria-label')).toContain('2026-10-20'); - changeDate(end.id, '2026-12-10'); - expect(document.activeElement).toBe(end); - expect(field(end.id)).toBe(end); - expect(point(2).getAttribute('aria-label')).toContain('2026-12-03'); - expect(document.getElementById('preview-date-changes')?.textContent).toContain('2026-10-20 → 2026-11-20'); - expect(field('preview-selected-date').disabled).toBe(false); -}); - -test('keeps single-step and legacy33 targets while exposing overlapping points through the Step selector', () => { - openPlanSchedulePreview({ ...draft, preset: 'immediate' }, parent); - expect(document.getElementById('preview-range-end')).toBeNull(); - changeDate('preview-range-start', '2026-10-21'); - expect(point(0).getAttribute('aria-label')).toContain('2026-10-21, target 80%'); - openPlanSchedulePreview({ ...draft, preset: 'custom', stepPercent: '33' }, parent); - changeDate('preview-range-end', '2026-10-22'); - expect(point(2).getAttribute('aria-label')).toContain('2026-10-22, target 79.2%'); - openPlanSchedulePreview({ ...draft, coverage: '0' }, parent); - changeDate('preview-range-end', '2027-10-20'); - const selector = document.getElementById('preview-step-select') as HTMLSelectElement; - for (const [index, date] of [[1, '2026-10-21'], [2, '2026-10-22']] as const) { - selector.value = String(index); selector.dispatchEvent(new Event('change')); - changeDate('preview-selected-date', date); - } - const left = (index: number): number => Number(point(index).querySelector('circle')!.getAttribute('cx')); - expect(left(1) - left(0)).toBeLessThan(3); - for (let index = 0; index < 4; index++) { - selector.value = String(index); selector.dispatchEvent(new Event('change')); - expect(point(index).getAttribute('aria-pressed')).toBe('true'); - expect(field('preview-selected-date').value).toBe(['2026-10-20', '2026-10-21', '2026-10-22', '2027-10-20'][index]); - } -}); - -test('rechecks operation-time UTC midnight and refuses stale reset without replacing repaired dates', () => { - openPlanSchedulePreview(draft, parent); - jest.setSystemTime(new Date('2026-10-20T00:00:00Z')); - changeDate('preview-selected-date', '2026-10-21'); - expect(point(0).getAttribute('aria-label')).toContain('2026-10-21'); - changeDate('preview-range-end', '2026-11-11'); - field('preview-reset-dates').click(); - expect(point(0).getAttribute('aria-label')).toContain('2026-10-21'); - expect(document.getElementById('preview-date-error')?.textContent).toMatch(/reopen/i); - point(2).dispatchEvent(new Event('click', { bubbles: true })); - jest.setSystemTime(new Date('2026-10-21T00:00:00Z')); - point(2).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', altKey: true, bubbles: true })); - expect(point(2).getAttribute('aria-label')).toContain('2026-11-04'); - expect(document.getElementById('preview-date-error')?.textContent).toMatch(/future/); -}); - - -const pointerPreview = (input: PlanSchedulePreviewInput = draft) => { - openPlanSchedulePreview(input, parent); - const svg = document.querySelector('#plan-schedule-preview svg')!; - let captured = false; - Object.assign(svg, { - getScreenCTM: () => ({ inverse: () => ({}) }), - createSVGPoint: () => ({ x: 0, y: 0, matrixTransform() { return { x: this.x, y: this.y }; } }), - setPointerCapture: jest.fn(() => { captured = true; }), - hasPointerCapture: () => captured, - releasePointerCapture: jest.fn(() => { captured = false; }), - }); - const send = (type: string, target: Element, x: number, id = 1, primary = true): void => { - target.dispatchEvent(Object.assign(new Event(type, { bubbles: true, cancelable: true }), - { pointerId: id, isPrimary: primary, button: 0, clientX: x, clientY: 0 })); - }; - const x = (index: number): number => Number(point(index).querySelector('circle')!.getAttribute('cx')); - return { svg, send, x, day: 544 / ((input.preset === 'immediate' ? 0 : (Math.floor(100 / Number(input.stepPercent)) - 1) * 7) + 8) }; -}; - -test('drags dates from off-center grabs without jumping and keeps the frozen scale, shares and nodes', () => { - const { svg, send, x, day } = pointerPreview(); - const input = field('preview-selected-date'), node = point(1), origin = x(1) + 18; - send('pointerdown', node, origin); - Object.assign(svg, { getScreenCTM: () => { throw new Error('Coordinate frame must stay frozen'); } }); - send('pointermove', svg, origin + day / 4); - expect(node.getAttribute('aria-label')).toContain('2026-10-27'); - send('pointermove', svg, origin + day); - expect(node.getAttribute('aria-label')).toContain('2026-10-28'); - expect(input.value).toBe('2026-10-28'); - expect(document.activeElement).toBe(node); - expect(input.disabled).toBe(true); - expect(x(1)).toBeCloseTo(origin - 18 + day); - send('pointerup', svg, origin + day); - expect(input.disabled).toBe(false); - expect(document.activeElement).toBe(node); - expect(point(1)).toBe(node); - expect(point(1).getAttribute('aria-label')).toContain('target 40%'); - expect(parent.querySelector('input')?.value).toBe('unchanged'); -}); - -test.each(['pointercancel', 'lostpointercapture', 'Escape'])('rolls back a captured draft on %s and closes only on a subsequent Escape', reason => { - const { svg, send, x, day } = pointerPreview(), origin = x(1); - send('pointerdown', point(1), origin); - send('pointermove', svg, origin + day); - expect(field('preview-selected-date').value).toBe('2026-10-28'); - if (reason === 'Escape') point(1).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - else send(reason, svg, origin + day); - expect(field('preview-selected-date').value).toBe('2026-10-27'); - expect(document.getElementById('preview-date-changes')?.textContent).toContain('(0)'); - expect(svg.releasePointerCapture).toHaveBeenCalled(); - expect(document.getElementById('plan-schedule-preview')).not.toBeNull(); - point(1).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - expect(document.getElementById('plan-schedule-preview')).toBeNull(); -}); - -test('redistributes endpoint gestures, focuses their owner before disabling inputs and supplies keyboard activation', () => { - const { svg, send, day } = pointerPreview(); - const handle = document.querySelector('[data-preview-boundary="end"]')!; - const origin = Number(handle.querySelector('.preview-handle-marker')!.getAttribute('x')) + 6; - field('preview-range-end').focus(); - send('pointerdown', handle, origin); - expect(document.activeElement).toBe(handle); - send('pointermove', svg, origin + day * 3); - expect(field('preview-range-end').value).toBe('2026-11-13'); - expect(point(1).getAttribute('aria-label')).toContain('2026-10-28'); - handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); - expect(field('preview-range-end').value).toBe('2026-11-13'); - send('pointerup', svg, origin + day * 3); - handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); - expect(field('preview-range-end').value).toBe('2026-11-14'); - handle.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })); - expect(document.activeElement).toBe(field('preview-range-end')); -}); - -test('rejects collisions/outside release, ignores other pointers and dirty input, and releases capture on close', () => { - const { svg, send, x, day } = pointerPreview(), origin = x(1); - send('pointerdown', svg, 100); expect(svg.setPointerCapture).not.toHaveBeenCalled(); - send('pointerdown', point(1), origin, 2, false); - send('pointermove', svg, origin + day, 2); - expect(point(1).getAttribute('aria-label')).toContain('2026-10-27'); - send('pointerdown', point(1), origin); - send('pointermove', svg, origin + day, 2); - send('pointermove', svg, origin + day * 7); - expect(field('preview-selected-date').value).toBe('2026-10-27'); - expect(document.getElementById('preview-date-error')?.textContent).toMatch(/order/); - send('pointerup', svg, 1000); - expect(document.getElementById('preview-date-error')?.textContent).toMatch(/plot/i); - field('preview-selected-date').value = ''; - field('preview-selected-date').dispatchEvent(new Event('input')); - send('pointerdown', point(1), origin); - expect(field('preview-selected-date').value).toBe(''); - field('preview-reset-dates').click(); - send('pointerdown', point(1), x(1)); - field('close-schedule-preview').click(); - expect(svg.releasePointerCapture).toHaveBeenCalledTimes(2); - openPlanSchedulePreview({ ...draft, preset: 'immediate' }, parent); - expect(document.querySelector('[data-preview-boundary]')).toBeNull(); -}); - -test('rechecks midnight on release and rolls back exactly with a reopen warning', () => { - const { svg, send, x, day } = pointerPreview(), origin = x(1); - send('pointerdown', point(1), origin); - send('pointermove', svg, origin + day); - jest.setSystemTime(new Date('2026-10-20T00:00:00Z')); - send('pointerup', svg, origin + day); - expect(point(1).getAttribute('aria-label')).toContain('2026-10-27'); - expect(document.getElementById('preview-date-error')?.textContent).toMatch(/reopen/i); - expect(field('preview-selected-date').disabled).toBe(false); -}); - - -test('reports capture failure and leaves dates and date-input access intact', () => { - const { svg, send, x } = pointerPreview(); - Object.assign(svg, { setPointerCapture: () => { throw new Error('Capture unavailable'); } }); - const hit = point(1).querySelector('.preview-touch-target')!; - expect(hit.namespaceURI).toBe('http://www.w3.org/1999/xhtml'); - expect(hit.tabIndex).toBe(-1); - send('pointerdown', hit, x(1)); - expect(point(1).getAttribute('aria-label')).toContain('2026-10-27'); - expect(field('preview-selected-date').disabled).toBe(false); - expect(document.getElementById('preview-date-error')?.textContent).toContain('Use date inputs'); -}); - - -test('plots logical ordinals without bridging missing dates or relabeling retained steps', () => { - const points = [{ step: 2, date: '2026-10-22', target: 33 }, { step: 4, date: '2026-10-29', target: 99 }]; - const frame = schedulePlotFrame(points.map(point => point.date)); - const host = document.createElement('div'); - host.innerHTML = renderSchedulePlot(points, frame, 3, false); - const svg = host.querySelector('svg')!; - expect(svg.querySelector('[data-preview-step="1"]')?.getAttribute('aria-label')).toContain('Step 2,'); - expect(svg.querySelector('[data-preview-step="3"]')?.hasAttribute('data-selected')).toBe(true); - expect(svg.querySelector('[data-preview-step="0"]')).toBeNull(); - expect(svg.querySelector('.preview-staircase')?.getAttribute('d')).toBe(` M ${schedulePlotX(points[0]!.date, frame)} ${260 - 33 * 2.2} M ${schedulePlotX(points[1]!.date, frame)} ${260 - 99 * 2.2}`); - expect(svg.querySelector('[role="button"]')).toBeNull(); - const retained = svg.querySelector('[data-preview-step="3"]'); - const updated = [points[0]!, { ...points[1]!, step: 3, target: 66 }]; - updateSchedulePlot(svg, updated, frame, 2, false); - expect(svg.querySelector('[data-preview-step="2"]')).toBe(retained); - expect(retained?.getAttribute('aria-label')).toContain('Step 3,'); - expect(svg.querySelector('.preview-staircase')?.getAttribute('d')).toBe(` M ${schedulePlotX(points[0]!.date, frame)} ${260 - 33 * 2.2} H ${schedulePlotX(points[1]!.date, frame)} V ${260 - 66 * 2.2}`); -}); - -test('preserves contiguous plot geometry and uses only the known calendar extent', () => { - const points = [{ step: 1, date: '2026-10-22', target: 25 }, { step: 2, date: '2026-10-29', target: 50 }]; - const frame = schedulePlotFrame(points.map(point => point.date)); - const host = document.createElement('div'); - host.innerHTML = renderSchedulePlot(points, frame, 0, true); - expect(host.querySelector('.preview-staircase')?.getAttribute('d')).toBe(`M 48 260 H ${schedulePlotX(points[0]!.date, frame)} V 205 H ${schedulePlotX(points[1]!.date, frame)} V 150`); - expect(host.querySelectorAll('[data-preview-boundary]')).toHaveLength(2); - expect(host.querySelector('[data-preview-step="0"]')?.getAttribute('tabindex')).toBe('0'); - expect(schedulePlotFrame([...points.map(point => point.date)].reverse())).toEqual(frame); - expect(() => schedulePlotFrame([])).toThrow('No dated steps'); - expect(schedulePlotFrame(['2026-10-22']).span).toBe(8); - expect(schedulePlotFrame(Array.from({ length: 100 }, () => '2026-10-22')).width).toBe(4896); -}); diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 963e2f30..a436840a 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -212,20 +212,6 @@ describe('Plans Module', () => { }); describe('loadPlans', () => { - test('wires actual form values to the no-write schedule preview', () => { - const button = document.createElement('button'); - button.id = 'preview-plan-schedule-btn'; - document.getElementById('plan-modal')!.appendChild(button); - (document.querySelector('input[value="custom"]') as HTMLInputElement).checked = true; - (document.getElementById('ramp-step-percent') as HTMLInputElement).value = '33'; - setupPlanHandlers(); - button.click(); - expect(document.querySelectorAll('[data-preview-step]')).toHaveLength(3); - expect(api.createPlan).not.toHaveBeenCalled(); - expect(api.updatePlan).not.toHaveBeenCalled(); - expect(api.createPlannedPurchases).not.toHaveBeenCalled(); - }); - test('fetches and renders plans', async () => { (api.getPlans as jest.Mock).mockResolvedValue({ plans: [ diff --git a/frontend/src/index.html b/frontend/src/index.html index b5ac42f2..444da187 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -980,7 +980,6 @@

Automation Settings

diff --git a/frontend/src/plan-schedule-preview.ts b/frontend/src/plan-schedule-preview.ts deleted file mode 100644 index b66a473d..00000000 --- a/frontend/src/plan-schedule-preview.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { closeModal, openModal } from './modal'; -import { wirePlanDateInteraction } from './plan-date-interaction'; -import { renderSchedulePlot, updateSchedulePlot, schedulePlotFrame, SchedulePlotFrame } from './plan-schedule-plot'; -import { escapeHtml } from './utils'; -import { buildLocalScheduleIntent, PlanScheduleIntentInput } from './plan-schedule-intent'; -import { redistributeDraftDates, validateDraftDates } from './lib/plan-draft-dates'; - -export type PlanSchedulePreviewInput = PlanScheduleIntentInput; - -export function openPlanSchedulePreview(input: PlanSchedulePreviewInput, parent: HTMLElement): void { - const { coverage, notificationDays: notice, dates: proposedDates, shares } = buildLocalScheduleIntent(input); - const count = proposedDates.length; - const points = proposedDates.map((date, index) => ({ step: index + 1, date, target: coverage * shares[index]! / 100 })); - const initialDates = points.map(point => point.date); - let dates = [...initialDates], selected = 0; - let dirty: 'step' | 'range' | null = null; - let gesture: SchedulePlotFrame | null = null; - const previous = document.getElementById('plan-schedule-preview'); - if (previous) { closeModal(previous); previous.remove(); } - const modal = document.createElement('div'); - modal.id = 'plan-schedule-preview'; - modal.className = 'modal hidden'; - modal.setAttribute('role', 'dialog'); - modal.setAttribute('aria-modal', 'true'); - modal.setAttribute('aria-labelledby', 'schedule-preview-title'); - const frame = (): SchedulePlotFrame => gesture ?? schedulePlotFrame(dates); - modal.innerHTML = ` - `; - document.body.appendChild(modal); - const buttons = Array.from(modal.querySelectorAll('[data-preview-step]')); - const handles = Array.from(modal.querySelectorAll('[data-preview-boundary]')); - const svg = modal.querySelector('svg')!; - const dateInput = modal.querySelector('#preview-selected-date')!; - const rangeStart = modal.querySelector('#preview-range-start')!; - const rangeEnd = modal.querySelector('#preview-range-end'); - const stepSelect = modal.querySelector('#preview-step-select')!; - const error = (message: string): void => { modal.querySelector('#preview-date-error')!.textContent = message; }; - const lockControls = (): void => { - dateInput.disabled = !!gesture || dirty === 'range'; - rangeStart.disabled = !!gesture || dirty === 'step'; - if (rangeEnd) rangeEnd.disabled = !!gesture || dirty === 'step'; - stepSelect.disabled = dirty !== null || !!gesture; - modal.querySelector('#preview-reset-dates')!.disabled = !!gesture; - [...buttons, ...handles].forEach(button => button.setAttribute('aria-disabled', String(dirty !== null || !!gesture))); - }; - const select = (index: number, focus: boolean): void => { - if (dirty || (gesture && index !== selected)) return; - selected = index; - const point = points[index]!; - updateSchedulePlot(svg, points.map((point, i) => ({ ...point, date: dates[i]! })), frame(), selected, true); - modal.querySelector('#preview-selected-step')!.textContent = `Step ${index + 1}`; - if (dateInput.value !== dates[index]) dateInput.value = dates[index]!; - stepSelect.value = String(index); - modal.querySelector('#preview-selected-target')!.textContent = `Planned target: ${point.target}%`; - if (focus) buttons[index]!.focus(); - }; - const renderDates = (): void => { - modal.querySelector('#preview-start')!.textContent = dates[0]!; - modal.querySelector('#preview-end')!.textContent = dates[count - 1]!; - if (rangeStart.value !== dates[0]) rangeStart.value = dates[0]!; - if (rangeEnd && rangeEnd.value !== dates[count - 1]) rangeEnd.value = dates[count - 1]!; - const changes = dates.flatMap((date, index) => date === initialDates[index] ? [] : [`Step ${index + 1}: ${initialDates[index]} → ${date}`]); - modal.querySelector('#preview-date-changes summary')!.textContent = `Draft date changes (${changes.length})`; - const items = changes.map(change => { const item = document.createElement('li'); item.textContent = change; return item; }); - modal.querySelector('#preview-date-changes ul')!.replaceChildren(...items); - modal.querySelector('#preview-date-status')!.textContent = `Draft dates changed: ${changes.length}. Nothing is saved.`; - select(selected, false); - }; - const apply = (candidate: () => string[]): void => { - try { - const next = candidate(); - validateDraftDates(next); - dates = next; dirty = null; error(''); lockControls(); renderDates(); - } catch (reason) { error(reason instanceof Error ? reason.message : String(reason)); } - }; - [dateInput, rangeStart, ...(rangeEnd ? [rangeEnd] : [])].forEach(control => { - const group = control === dateInput ? 'step' : 'range'; - const begin = (): boolean => { - if (gesture || (dirty && dirty !== group)) return false; - dirty = group; lockControls(); return true; - }; - control.addEventListener('input', () => { begin(); }); - control.addEventListener('change', () => { - if (begin()) apply(() => control === dateInput - ? dates.map((date, index) => index === selected ? dateInput.value : date) - : redistributeDraftDates(dates, rangeStart.value, rangeEnd?.value ?? rangeStart.value)); - }); - }); - stepSelect.addEventListener('change', () => select(Number(stepSelect.value), false)); - modal.querySelector('#preview-reset-dates')!.addEventListener('click', () => { if (gesture) return; apply(() => { - try { validateDraftDates(initialDates); } - catch { throw new Error('Initial dates are no longer future dates. Close and reopen the preview for fresh dates.'); } - return [...initialDates]; - }); }); - const disposeInteraction = wirePlanDateInteraction(svg, { - read: () => ({ dates, blocked: dirty !== null }), - select: index => select(index, false), - gesture: current => { gesture = current; lockControls(); renderDates(); }, - apply: ({ index, boundary, date, before }) => apply(() => boundary ? redistributeDraftDates(before, - boundary === 'start' ? date : before[0]!, boundary === 'end' ? date : before[before.length - 1]!) - : before.map((value, i) => i === index ? date : value)), - restore: before => { dates = [...before]; renderDates(); }, - focusDate: boundary => (boundary === 'start' ? rangeStart : rangeEnd!).focus(), - error, - }); - modal.querySelector('#close-schedule-preview')!.addEventListener('click', () => closeModal(modal)); - const wasInert = parent.inert; - parent.inert = true; - renderDates(); - openModal(modal, { onClose: () => { - disposeInteraction(); parent.inert = wasInert; modal.remove(); - } }); -} diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index c7d70651..55398c8a 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -17,8 +17,7 @@ import { showSkeletonTiles, showSkeletonRows, teardownSkeleton } from './lib/ske import { canAccess } from './permissions'; import { parseNumericFilter, applyColumnFilters as applyColumnFiltersLib } from './lib/column-filters'; import { suggestedPurchaseStartDate } from './lib/calendar-date'; -import { openPlanSchedulePreview } from './plan-schedule-preview'; -import { mountPlanEditorPanel, readPlanScheduleInput } from './plan-editor-panel'; +import { mountPlanEditorPanel } from './plan-editor-panel'; // pendingPlanRecommendations holds the resolved plan target captured at // "Plan from N selected" button-click time. The Plan flow used to re-derive @@ -2390,14 +2389,6 @@ let _unsubAccount: (() => void) | null = null; * the page level. */ export function setupPlanHandlers(): void { - const preview = document.getElementById('preview-plan-schedule-btn'); - if (preview) preview.onclick = () => { - const parent = document.getElementById('plan-modal'), form = document.getElementById('plan-form') as HTMLFormElement | null; - if (!parent || !form) return; - try { - openPlanSchedulePreview(readPlanScheduleInput(form), parent); - } catch (error) { showToast({ message: (error as Error).message, kind: 'error' }); } - }; // Drop any previous subscriptions before re-registering so we don't // accumulate duplicate loadPlans() calls on each invocation. _unsubProvider?.(); diff --git a/frontend/src/styles/plans.css b/frontend/src/styles/plans.css index 050ad821..2a8253db 100644 --- a/frontend/src/styles/plans.css +++ b/frontend/src/styles/plans.css @@ -234,21 +234,6 @@ border-radius: 8px; } -#plan-schedule-preview { z-index: 1010; } -#plan-schedule-preview .schedule-preview-panel { - width: min(1280px, calc(100vw - 48px)); max-width: none; - max-height: calc(100dvh - 48px); padding: 0; overflow: hidden; - display: flex; flex-direction: column; -} -.schedule-preview-panel header, .schedule-preview-panel footer { padding: 20px 24px; flex: none; } -.schedule-preview-panel header { display: block; background: var(--cudly-surface); color: var(--cudly-text); border-bottom: 1px solid var(--cudly-border); } -.schedule-preview-panel header h2 { margin: 0; font-size: 22px; } -.schedule-preview-panel header p { margin: 6px 0 0; color: var(--cudly-text-muted); overflow-wrap: anywhere; } -.schedule-preview-panel footer { border-top: 1px solid var(--cudly-border); text-align: right; } -.schedule-preview-body { padding: 24px; overflow: auto; min-height: 0; } -.schedule-preview-notice { margin: 0 0 24px; padding: 12px; background: var(--cudly-info-bg); border-radius: 8px; } -.schedule-preview-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 24px; } -.schedule-preview-layout h3 { margin: 0 0 12px; font-size: 16px; } .schedule-preview-scroll { display: flex; align-items: flex-start; overflow: auto; max-width: 100%; border: 1px solid var(--cudly-border); border-radius: 8px; } .schedule-preview-scroll svg { display: block; flex: 1 0 auto; max-width: none; height: auto; } .schedule-preview-scroll text { fill: var(--cudly-text-muted); font: 12px system-ui; font-variant-numeric: tabular-nums; pointer-events: none; } @@ -258,28 +243,14 @@ .preview-marker { fill: var(--cudly-surface); stroke: var(--cudly-primary); stroke-width: 2; pointer-events: none; } [data-preview-step][aria-pressed="true"] .preview-marker { fill: var(--cudly-primary); stroke-width: 4; } [data-preview-step]:focus-visible .preview-marker { stroke: var(--cudly-text); stroke-width: 5; } -.schedule-preview-layout dl { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 0 0 24px; } -.schedule-preview-layout dt { color: var(--cudly-text-muted); } -.schedule-preview-layout dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } -.schedule-preview-layout input, .schedule-preview-layout select { display: block; width: 100%; box-sizing: border-box; margin: 4px 0 12px; padding: 8px; border: 1px solid var(--cudly-border); border-radius: 4px; } -.schedule-preview-layout input:focus-visible, .schedule-preview-layout select:focus-visible { outline: 2px solid var(--cudly-primary); outline-offset: 2px; } -#preview-date-error { color: var(--cudly-error); overflow-wrap: anywhere; } -#preview-date-changes li { margin-bottom: 8px; overflow-wrap: anywhere; } -.schedule-preview-layout details { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--cudly-border); } -.schedule-preview-layout summary { cursor: pointer; margin-bottom: 12px; } .preview-touch-target { width: 100%; height: 100%; touch-action: none; cursor: ew-resize; } .preview-handle-marker { fill: var(--cudly-primary); stroke: var(--cudly-surface); stroke-width: 2; pointer-events: none; } [data-preview-boundary]:focus-visible .preview-handle-marker { stroke: var(--cudly-text); stroke-width: 4; } .preview-interval { stroke: var(--cudly-border-strong); fill: none; pointer-events: none; } -@media (max-width: 900px) { - #plan-schedule-preview .schedule-preview-panel { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); } - .schedule-preview-layout { grid-template-columns: minmax(0, 1fr); } - .schedule-preview-body { padding: 16px; } -} -:is(#plan-schedule-preview, #plan-editor-panel) [data-preview-step]:focus-visible, :is(#plan-schedule-preview, #plan-editor-panel) [data-preview-boundary]:focus-visible { outline: none; } -:is(#plan-schedule-preview, #plan-editor-panel) [data-preview-step]:focus-visible .preview-touch-target, :is(#plan-schedule-preview, #plan-editor-panel) [data-preview-boundary]:focus-visible .preview-touch-target { outline: 2px solid var(--cudly-primary); outline-offset: -2px; } +#plan-editor-panel [data-preview-step]:focus-visible, #plan-editor-panel [data-preview-boundary]:focus-visible { outline: none; } +#plan-editor-panel [data-preview-step]:focus-visible .preview-touch-target, #plan-editor-panel [data-preview-boundary]:focus-visible .preview-touch-target { outline: 2px solid var(--cudly-primary); outline-offset: -2px; } #plan-modal .modal-content { width: min(1280px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); display: flex; flex-direction: column; overflow: hidden; } #plan-form { min-width: 0; display: flex; flex-direction: column; min-height: 0; } diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts index 7ca79a45..bcbd9a22 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Locator } from '@playwright/test'; +import { test, expect } from '@playwright/test'; test.use({ hasTouch: true }); import { mockApi, seedAuth } from './fixtures/recs'; @@ -32,6 +32,8 @@ for (const [width, height] of [[1440, 900], [1280, 900], [1280, 768], [1024, 900 await page.locator(mode === 'Create' ? '#new-plan-btn' : '[data-action="edit-plan"][data-id="draft-plan"]').click(); const parent = page.locator('#plan-modal'); await expect(parent).toBeVisible(); + await expect(page.locator('#preview-plan-schedule-btn')).toHaveCount(0); + await expect(page.locator('#plan-schedule-preview')).toHaveCount(0); await expect(parent.locator('#plan-name')).toBeFocused(); const advanced = parent.locator('#plan-advanced-settings'), summary = advanced.locator(':scope > summary'); await expect(advanced).not.toHaveAttribute('open', ''); @@ -130,163 +132,26 @@ for (const [width, height] of [[1440, 900], [1280, 900], [1280, 768], [1024, 900 expect(cancel.x).toBeGreaterThanOrEqual(0); expect(cancel.x + cancel.width).toBeLessThanOrEqual(width); const footerBox = (await parent.locator('.modal-buttons').boundingBox())!; expect(footerBox.y + footerBox.height).toBeLessThanOrEqual(page.viewportSize()!.height); - const before = await parent.locator('input, select, textarea').evaluateAll(elements => elements.map(element => (element as HTMLInputElement).value)); - const trigger = page.locator('#preview-plan-schedule-btn'); - await trigger.click(); - const preview = page.locator('#plan-schedule-preview'); - await expect(preview).toBeVisible(); - await expect(preview).toContainText('These dates are not saved or scheduled'); - await expect(preview).toContainText('Saved purchases and history are not shown'); - await expect(preview).toContainText('Amount unavailable'); - await expect(preview).toContainText('Save Plan does not apply these date edits'); - expect(await preview.locator('header').evaluate(element => getComputedStyle(element).display)).toBe('block'); - await expect(preview.locator('[data-preview-step]')).toHaveCount(4); - expect(await parent.evaluate(element => element.inert)).toBe(true); - expect(await preview.locator('aside details').evaluate(element => element.open)).toBe(false); - await preview.getByRole('button', { name: 'Close preview' }).focus(); - await page.keyboard.press('Tab'); - await expect(preview.locator('.schedule-preview-scroll')).toBeFocused(); - await page.keyboard.press('Shift+Tab'); - await expect(preview.getByRole('button', { name: 'Close preview' })).toBeFocused(); - const point = preview.locator('[data-preview-step="2"]'); - await point.locator('.preview-hit').click(); - await expect(preview.locator('#preview-selected-step')).toHaveText('Step 3'); - await point.press('ArrowRight'); - await expect(preview.locator('[data-preview-step="3"]')).toBeFocused(); - await page.keyboard.press(' '); - await page.keyboard.press('Enter'); - await expect(preview.locator('#preview-selected-target')).toHaveText('Planned target: 80%'); - const addDays = (date: string, days: number): string => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); - const rangeStart = preview.locator('#preview-range-start'), rangeEnd = preview.locator('#preview-range-end'); - const original = await rangeStart.inputValue(), later = addDays(await rangeEnd.inputValue(), 2); - - const beginDrag = async (target: Locator, offset = 0) => { - await target.scrollIntoViewIfNeeded(); - const box = (await target.boundingBox())!, svgBox = (await preview.locator('svg').boundingBox())!; - const svgWidth = await preview.locator('svg').evaluate(element => (element as SVGSVGElement).viewBox.baseVal.width); - const span = (Date.parse(await rangeEnd.inputValue()) - Date.parse(await rangeStart.inputValue())) / 86_400_000 + 8; - const origin = { x: box.x + box.width / 2 + offset, y: box.y + box.height / 2 }; - await preview.locator('svg').evaluate(element => element.addEventListener('pointerdown', event => element.setAttribute('data-test-pointer-id', String((event as PointerEvent).pointerId)), { once: true })); - await page.mouse.move(origin.x, origin.y); await page.mouse.down(); - return { ...origin, id: Number(await preview.locator('svg').getAttribute('data-test-pointer-id')), day: svgBox.width / svgWidth * (svgWidth - 96) / span, plotLeft: svgBox.x + 48 * svgBox.width / svgWidth }; - }; - const firstHit = preview.locator('[data-preview-step="0"] .preview-hit'); - let drag = await beginDrag(firstHit, 10); - await page.mouse.move(drag.x + drag.day / 4, drag.y); - await expect(rangeStart).toHaveValue(original); - await page.mouse.move(drag.x + drag.day, drag.y); await page.mouse.up(); - await expect(rangeStart).toHaveValue(addDays(original, 1)); - await expect(preview.locator('[data-preview-step="0"]')).toBeFocused(); - await preview.getByRole('button', { name: 'Reset draft dates' }).click(); - drag = await beginDrag(firstHit); - await page.mouse.move(drag.x + drag.day, drag.y); - await page.mouse.move(drag.x + drag.day * 7, drag.y); - await expect(preview.locator('#preview-date-error')).toContainText('order'); - await page.mouse.move(drag.plotLeft - 3, drag.y); await page.mouse.up(); - await expect(rangeStart).toHaveValue(addDays(original, 1)); - await expect(preview.locator('#preview-date-error')).toContainText('plot'); - await preview.getByRole('button', { name: 'Reset draft dates' }).click(); - const endHandle = preview.locator('[data-preview-boundary="end"]'); - await rangeEnd.focus(); - drag = await beginDrag(endHandle.locator('.preview-handle-hit')); - await expect(endHandle).toBeFocused(); - await expect(rangeEnd).toBeDisabled(); - await page.mouse.move(drag.x + drag.day * 3, drag.y); await page.mouse.up(); - await expect(rangeEnd).toHaveValue(addDays(later, 1)); - await endHandle.press('Enter'); await expect(rangeEnd).toBeFocused(); - const startHandle = preview.locator('[data-preview-boundary="start"]'); - drag = await beginDrag(startHandle.locator('.preview-handle-hit')); - await page.mouse.move(drag.x + drag.day, drag.y); await page.mouse.up(); - await expect(rangeStart).toHaveValue(addDays(original, 1)); - await expect(preview.locator('[data-preview-step="3"]')).toHaveAttribute('aria-label', /target 80%/); - await preview.getByRole('button', { name: 'Reset draft dates' }).click(); - drag = await beginDrag(firstHit); - await page.mouse.move(drag.x + drag.day, drag.y); - await preview.locator('svg').evaluate((element, id) => (element as SVGSVGElement).releasePointerCapture(id), drag.id); - await page.mouse.move(drag.x + drag.day + 1, drag.y); - await expect(rangeStart).toHaveValue(original); await page.mouse.up(); - await expect(preview.locator('#preview-date-changes summary')).toHaveText('Draft date changes (0)'); - drag = await beginDrag(firstHit); - await page.mouse.move(drag.x + drag.day, drag.y); - await page.keyboard.press('Escape'); await page.mouse.up(); - await expect(preview).toBeVisible(); - await expect(rangeStart).toHaveValue(original); - await page.keyboard.press('Escape'); await expect(preview).toHaveCount(0); - await expect(trigger).toBeFocused(); await trigger.click(); - if (width <= 390) { - const session = await page.context().newCDPSession(page); - try { - for (const [target, control] of [[firstHit, rangeStart], [endHandle.locator('.preview-handle-hit'), rangeEnd]] as const) { - await target.scrollIntoViewIfNeeded(); - const box = (await target.boundingBox())!, svgBox = (await preview.locator('svg').boundingBox())!; - const svgWidth = await preview.locator('svg').evaluate(element => (element as SVGSVGElement).viewBox.baseVal.width); - const span = (Date.parse(await rangeEnd.inputValue()) - Date.parse(await rangeStart.inputValue())) / 86_400_000 + 8; - const x = box.x + box.width / 2, y = box.y + box.height / 2, day = svgBox.width / svgWidth * (svgWidth - 96) / span; - const beforeDate = await control.inputValue(); - const labels = await preview.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label'))); - await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] }); - await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: x + day, y, id: 1 }] }); - await expect(control).toHaveValue(addDays(beforeDate, 1)); - await session.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] }); - await expect(control).toHaveValue(beforeDate); - expect(await preview.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')))).toEqual(labels); - } - } finally { await session.detach(); } - } - const endNode = await rangeEnd.elementHandle(); - await rangeStart.fill(later); await rangeStart.dispatchEvent('change'); - await expect(preview.locator('#preview-date-error')).not.toBeEmpty(); - await expect(rangeStart).toHaveValue(later); - await expect(preview.locator('#preview-step-select')).toBeDisabled(); - await rangeEnd.fill(addDays(later, 12)); await rangeEnd.dispatchEvent('change'); - await expect(rangeEnd).toBeFocused(); - expect(await endNode!.evaluate(element => element.isConnected)).toBe(true); - await expect(preview.locator('[data-preview-step="2"]')).toHaveAttribute('aria-label', new RegExp(addDays(later, 8))); - await preview.locator('#preview-step-select').selectOption('1'); - const second = preview.locator('[data-preview-step="1"]'), date = preview.locator('#preview-selected-date'); - await second.focus(); await second.press('Alt+ArrowRight'); - await expect(second).toBeFocused(); - await expect(date).toHaveValue(addDays(later, 5)); - const dateNode = await date.elementHandle(); - await date.fill(addDays(later, 8)); await date.dispatchEvent('change'); - await expect(date).toBeFocused(); - expect(await dateNode!.evaluate(element => element.isConnected)).toBe(true); - await expect(second).toHaveAttribute('aria-label', new RegExp(`${addDays(later, 5)}, target 40%`)); - await expect(preview.locator('#preview-date-error')).toContainText('order'); - await preview.getByRole('button', { name: 'Reset draft dates' }).click(); - await expect(rangeStart).toHaveValue(original); - await expect(preview.locator('#preview-date-changes summary')).toHaveText('Draft date changes (0)'); - await date.fill(addDays(original, 8)); await date.dispatchEvent('change'); - await preview.locator('#preview-date-changes summary').click(); - await expect(preview.locator('#preview-date-changes')).toContainText(`${addDays(original, 7)} → ${addDays(original, 8)}`); - expect(await preview.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); - await preview.locator('.schedule-preview-body').evaluate(element => { element.scrollTop = 0; }); - await preview.locator('.schedule-preview-scroll').evaluate(element => { element.scrollLeft = 0; }); - await page.screenshot({ path: testInfo.outputPath(`${mode.toLowerCase()}-preview.png`), fullPage: true }); - await date.scrollIntoViewIfNeeded(); - await page.screenshot({ path: testInfo.outputPath(`${mode.toLowerCase()}-controls.png`), fullPage: true }); - await page.keyboard.press('Escape'); - await expect(preview).toHaveCount(0); - await expect(trigger).toBeFocused(); - await expect(parent).toBeVisible(); - expect(await parent.locator('input, select, textarea').evaluateAll(elements => elements.map(element => (element as HTMLInputElement).value))).toEqual(before); await page.locator('#close-plan-modal-btn').click(); } await page.locator('#new-plan-btn').click(); + const dense = page.locator('#plan-editor-panel'), scroll = dense.locator('.schedule-preview-scroll'); await page.locator('#plan-modal label.ramp-option').filter({ hasText: 'Define your own schedule' }).click(); await page.locator('#ramp-step-percent').fill('1'); - await page.locator('#preview-plan-schedule-btn').click(); - const dense = page.locator('#plan-schedule-preview'); await expect(dense.locator('[data-preview-step]')).toHaveCount(100); + await scroll.scrollIntoViewIfNeeded(); expect((await dense.locator('svg').boundingBox())!.width).toBeGreaterThanOrEqual(4896); - expect(await dense.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + expect(await scroll.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); + expect(await page.locator('#plan-modal').evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + expect(await page.locator('#plan-form').evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + await scroll.focus(); + await page.keyboard.press('ArrowRight'); + await expect.poll(() => scroll.evaluate(element => element.scrollLeft)).toBeGreaterThan(0); + await scroll.evaluate(element => { element.scrollLeft = 0; }); if (width <= 390) { - const scroll = dense.locator('.schedule-preview-scroll'); - await scroll.scrollIntoViewIfNeeded(); const box = (await scroll.boundingBox())!, svgBox = (await dense.locator('svg').boundingBox())!; - const y = svgBox.y + svgBox.height * 304 / 370, left = box.x + 40, right = box.x + box.width - 40; - const hit = await page.evaluate(({ x, y }) => { const element = document.elementFromPoint(x, y); return { tag: element?.tagName, control: !!element?.closest("[data-preview-step],[data-preview-boundary]"), action: element ? getComputedStyle(element).touchAction : null }; }, { x: right, y }); - expect(hit.tag).toBe('svg'); expect(hit.control).toBe(false); expect(hit.action).toBe('auto'); + const y = svgBox.y + svgBox.height / 2, left = box.x + 40, right = box.x + box.width - 40; const beforeScroll = await scroll.evaluate(element => element.scrollLeft); const labels = await dense.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label'))); const session = await page.context().newCDPSession(page); @@ -298,43 +163,8 @@ for (const [width, height] of [[1440, 900], [1280, 900], [1280, 768], [1024, 900 await expect.poll(() => scroll.evaluate(element => element.scrollLeft)).toBeGreaterThan(beforeScroll); expect(await dense.locator('[data-preview-step]').evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')))).toEqual(labels); } finally { await session.detach(); } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); } - await dense.locator('[data-preview-step="0"]').focus(); - await page.keyboard.press('ArrowRight'); - await expect(dense.locator('#preview-selected-step')).toHaveText('Step 2'); - await expect(dense.locator('[data-preview-step="1"] .preview-touch-target')).toHaveCSS('outline-width', '2px'); - await expect(dense.locator('[data-preview-step="1"] .preview-touch-target')).toHaveCSS('outline-style', 'solid'); - await page.keyboard.press('Escape'); - const parent = page.locator('#plan-modal'); - await parent.locator('label.ramp-option').filter({ hasText: 'Weekly 25%' }).click(); - await page.locator('#plan-coverage').fill('0'); - await page.locator('#preview-plan-schedule-btn').click(); - const overlap = page.locator('#plan-schedule-preview'), step = overlap.locator('#preview-step-select'); - const firstDate = await overlap.locator('#preview-range-start').inputValue(); - const after = (days: number): string => new Date(Date.parse(`${firstDate}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); - await overlap.locator('#preview-range-end').fill(after(365)); await overlap.locator('#preview-range-end').dispatchEvent('change'); - for (const index of [1, 2]) { - await step.selectOption(String(index)); - await overlap.locator('#preview-selected-date').fill(after(index)); await overlap.locator('#preview-selected-date').dispatchEvent('change'); - } - const positions = await overlap.locator('.preview-hit').evaluateAll(elements => elements.map(element => Number(element.getAttribute('x')) + 22)); - expect(positions[1]! - positions[0]!).toBeLessThan(3); - for (let index = 0; index < 4; index++) { - await step.selectOption(String(index)); - await expect(overlap.locator('#preview-selected-date')).toHaveValue(after(index === 3 ? 365 : index)); - } - await overlap.locator('[data-preview-step="0"]').focus(); - for (let index = 1; index < 4; index++) { - await page.keyboard.press('ArrowRight'); - await expect(overlap.locator('#preview-selected-step')).toHaveText(`Step ${index + 1}`); - } - await page.keyboard.press('Escape'); - await parent.locator('label.ramp-option:has(input[value="immediate"])').click(); - await page.locator('#preview-plan-schedule-btn').click(); - await expect(page.locator('#preview-range-end')).toHaveCount(0); - await page.locator('#preview-range-start').fill(after(3)); await page.locator('#preview-range-start').dispatchEvent('change'); - await expect(page.locator('#preview-selected-date')).toHaveValue(after(3)); - await page.keyboard.press('Escape'); expect(writes).toEqual([]); expect(errors).toEqual([]); await testInfo.attach('production-preview-receipt', { body: JSON.stringify({ width, isMobile, scripts, writes, errors }), contentType: 'application/json' });