From afdb78decc6a75cae8aaf0aa232cd051eb5f88aa Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 04:55:32 +0200 Subject: [PATCH 1/7] test(plans): keep the non-interactive plot cases before the dialog test goes The preview dialog test is the only place that covers the logical-ordinal plot (no bridging of missing dates, retained step nodes) and the schedulePlotFrame width rule. The inline panel still renders the plot, so move those cases to their own file ahead of deleting the dialog test. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/plan-schedule-plot.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 frontend/src/__tests__/plan-schedule-plot.test.ts 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 000000000..2700b22b8 --- /dev/null +++ b/frontend/src/__tests__/plan-schedule-plot.test.ts @@ -0,0 +1,31 @@ +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); +}); From a054b41a50f1bfdce06e14e4666d1fbe17587206 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 04:56:41 +0200 Subject: [PATCH 2/7] test(plans): cover the date gestures only the dialog test exercised plan-date-interaction.ts stays for the deferred editable path (#1057), and the dialog test is the only coverage of rollback on cancel, endpoint redistribution, collision and outside-release rejection, and capture failure. Port those cases to the panel test before the dialog test is deleted. Making the rollback a no-op now fails four tests. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/plan-editor-panel.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/frontend/src/__tests__/plan-editor-panel.test.ts b/frontend/src/__tests__/plan-editor-panel.test.ts index e8176fcda..089009953 100644 --- a/frontend/src/__tests__/plan-editor-panel.test.ts +++ b/frontend/src/__tests__/plan-editor-panel.test.ts @@ -169,3 +169,73 @@ 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 }); + 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); + 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('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'); + }); +}); From a76e221f31c3063ac557a03773bcf5286b36e238 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 04:57:21 +0200 Subject: [PATCH 3/7] test(e2e): drop the schedule preview dialog walkthrough The inline panel is now the only preview in Create and Edit, so the dialog gesture, keyboard and date-field sections of the spec are dead weight. The gestures they covered are ported to plan-editor-panel.test.ts. The dense and overlap block still opens the dialog and is replaced in the next commit. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- .../tests-e2e/plan-schedule-preview.spec.ts | 142 +----------------- 1 file changed, 1 insertion(+), 141 deletions(-) diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts index 7ca79a453..6b331e6a4 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'; @@ -130,146 +130,6 @@ 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(); From c3b9f1d6cca872a59b2892a9a64845fcd013d80f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 04:59:13 +0200 Subject: [PATCH 4/7] test(e2e): check the 100-point plot scrolls inside the inline preview The dense and overlap block drove the dialog. Replace it with the same guarantees on the inline panel: 100 points at a 4896px plot, only the preview scroll region scrolls horizontally (page, modal and form do not), and on phones a touch pan over the plot scrolls it without changing any point. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- .../tests-e2e/plan-schedule-preview.spec.ts | 56 ++++--------------- 1 file changed, 12 insertions(+), 44 deletions(-) diff --git a/frontend/tests-e2e/plan-schedule-preview.spec.ts b/frontend/tests-e2e/plan-schedule-preview.spec.ts index 6b331e6a4..e5a12614f 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -133,20 +133,23 @@ for (const [width, height] of [[1440, 900], [1280, 900], [1280, 768], [1024, 900 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); @@ -158,43 +161,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' }); From 938f98aa60ca3c2177ffb6f762607d961d9bbdea Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 05:02:49 +0200 Subject: [PATCH 5/7] test(plans): delete the preview dialog test Its cases now live elsewhere: intent building in plan-schedule-intent .test.ts, the non-interactive plot in plan-schedule-plot.test.ts and the date gestures in plan-editor-panel.test.ts. Also port the click and arrow selection case and the release-time midnight recheck, so plan-date-interaction.ts keeps 100% line coverage without the dialog test (stmts 96.87 -> 96.25, branches 85.43 -> 85.43; the plot keeps 100% lines, one branch of updateSchedulePlot's tick guard is no longer taken). Part of #1153 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/plan-editor-panel.test.ts | 19 ++ .../__tests__/plan-schedule-preview.test.ts | 316 ------------------ 2 files changed, 19 insertions(+), 316 deletions(-) delete mode 100644 frontend/src/__tests__/plan-schedule-preview.test.ts diff --git a/frontend/src/__tests__/plan-editor-panel.test.ts b/frontend/src/__tests__/plan-editor-panel.test.ts index 089009953..d1e378057 100644 --- a/frontend/src/__tests__/plan-editor-panel.test.ts +++ b/frontend/src/__tests__/plan-editor-panel.test.ts @@ -229,6 +229,25 @@ describe('editable date gestures', () => { 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'); } }); 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 1822be78a..000000000 --- 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); -}); From a2f702bec9340f9c70ed18d75d4b659e6d6631c4 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 05:03:20 +0200 Subject: [PATCH 6/7] feat(plans): remove the Preview schedule button and dialog The inline preview in the Create and Edit modal replaces the dialog, so the footer button, its handler, the dialog module and its CSS go. The e2e spec now asserts the button and dialog are absent. The dormant editable-dates path stays for #1057. Closes #1153 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/plans.test.ts | 14 -- frontend/src/index.html | 1 - frontend/src/plan-schedule-preview.ts | 147 ------------------ frontend/src/plans.ts | 11 +- frontend/src/styles/plans.css | 33 +--- .../tests-e2e/plan-schedule-preview.spec.ts | 2 + 6 files changed, 5 insertions(+), 203 deletions(-) delete mode 100644 frontend/src/plan-schedule-preview.ts diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 963e2f302..a436840a3 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 b5ac42f2a..444da187b 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 b66a473d6..000000000 --- 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 c7d706519..55398c8a7 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 050ad821d..2a8253dbd 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 e5a12614f..bcbd9a22f 100644 --- a/frontend/tests-e2e/plan-schedule-preview.spec.ts +++ b/frontend/tests-e2e/plan-schedule-preview.spec.ts @@ -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', ''); From 34bf36ef6b81a7981651bce7d43be68d2b897b4a Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 20 Oct 2026 05:24:31 +0200 Subject: [PATCH 7/7] test(plans): restore the step-1 staircase and Escape containment checks The deleted dialog test was the only check of the leading zero segment the plot emits when points start at step 1 (the inline preview always does), and of Escape not reaching the parent modal during a drag. Part of #1153 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/plan-editor-panel.test.ts | 3 ++- frontend/src/__tests__/plan-schedule-plot.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/plan-editor-panel.test.ts b/frontend/src/__tests__/plan-editor-panel.test.ts index d1e378057..f8aec3d03 100644 --- a/frontend/src/__tests__/plan-editor-panel.test.ts +++ b/frontend/src/__tests__/plan-editor-panel.test.ts @@ -192,10 +192,11 @@ describe('editable date gestures', () => { 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); + 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); }); diff --git a/frontend/src/__tests__/plan-schedule-plot.test.ts b/frontend/src/__tests__/plan-schedule-plot.test.ts index 2700b22b8..a345d712a 100644 --- a/frontend/src/__tests__/plan-schedule-plot.test.ts +++ b/frontend/src/__tests__/plan-schedule-plot.test.ts @@ -29,3 +29,11 @@ test('sizes the plot frame from the point count and rejects an empty schedule', 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`); +});