Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions frontend/src/__tests__/plan-editor-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SVGSVGElement>('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<SVGGElement>('[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<HTMLElement>('.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');
});
});
39 changes: 39 additions & 0 deletions frontend/src/__tests__/plan-schedule-plot.test.ts
Original file line number Diff line number Diff line change
@@ -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`);
});
Loading
Loading