Files
familienarchiv/frontend/src/lib/document/useTimelineDrag.svelte.test.ts
Marcel 9fd1f3cde2
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m5s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m22s
CI / Unit & Component Tests (push) Failing after 3m57s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m22s
refactor(documents): extract timeline drag state into rune class (#385)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:50:34 +02:00

156 lines
5.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { flushSync } from 'svelte';
const { createTimelineDrag } = await import('./useTimelineDrag.svelte');
type EmitCall = { start: number; end: number; includeZoom: boolean };
function makeOpts(overrides: Partial<Parameters<typeof createTimelineDrag>[0]> = {}) {
const labels = ['1915-08', '1915-09', '1915-10', '1915']; // last entry is a year label
const calls: EmitCall[] = [];
const opts = {
indexFromClientX: vi.fn(() => 0),
labelAt: (i: number) => labels[i],
isYearLabel: (l: string) => l.length === 4,
emit: (start: number, end: number, includeZoom: boolean) => {
calls.push({ start, end, includeZoom });
},
...overrides
};
return { opts, calls };
}
function pointerDownEvent(button = 0): PointerEvent {
return { button } as unknown as PointerEvent;
}
describe('createTimelineDrag', () => {
beforeEach(() => {
// Reset listeners attached to the JSDOM document between tests
document.removeEventListener('pointermove', () => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('starts with no drag in progress and null low/high indices', () => {
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
expect(drag.isDragging).toBe(false);
expect(drag.lowIndex).toBeNull();
expect(drag.highIndex).toBeNull();
});
it('pointerDown on a primary button enters drag state at that index', () => {
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 1);
flushSync();
expect(drag.isDragging).toBe(true);
expect(drag.lowIndex).toBe(1);
expect(drag.highIndex).toBe(1);
});
it('pointerDown on a non-primary button is ignored', () => {
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(2), 1);
flushSync();
expect(drag.isDragging).toBe(false);
});
it('pointerEnter during drag widens the range high boundary', () => {
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 0);
drag.pointerEnter(2);
flushSync();
expect(drag.lowIndex).toBe(0);
expect(drag.highIndex).toBe(2);
});
it('pointerEnter outside drag is a no-op', () => {
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerEnter(2);
flushSync();
expect(drag.isDragging).toBe(false);
expect(drag.lowIndex).toBeNull();
});
it('click on a month bar emits filter only (no zoom)', () => {
const { opts, calls } = makeOpts();
const drag = createTimelineDrag(opts);
drag.click(0);
expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
});
it('click on a year bar emits filter + zoom atomically', () => {
const { opts, calls } = makeOpts();
const drag = createTimelineDrag(opts);
drag.click(3); // labels[3] = '1915' (year label)
expect(calls).toEqual([{ start: 3, end: 3, includeZoom: true }]);
});
it('range drag commits emit with zoom in ascending order', async () => {
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 2) });
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 0);
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 999 }));
document.dispatchEvent(new PointerEvent('pointerup'));
flushSync();
expect(calls).toEqual([{ start: 0, end: 2, includeZoom: true }]);
expect(drag.isDragging).toBe(false);
});
it('reverse-direction drag still emits ascending boundaries via emit ordering', async () => {
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 2);
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 0 }));
document.dispatchEvent(new PointerEvent('pointerup'));
flushSync();
// emit receives raw start/end — orchestrator's emit() handles ordering
expect(calls).toEqual([{ start: 2, end: 0, includeZoom: true }]);
});
it('pointerup on the same bar (no-range) on a month label emits filter without zoom', () => {
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 0);
document.dispatchEvent(new PointerEvent('pointerup'));
flushSync();
expect(calls).toEqual([{ start: 0, end: 0, includeZoom: false }]);
});
it('pointercancel resets state without emitting', () => {
const { opts, calls } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 1);
document.dispatchEvent(new PointerEvent('pointercancel'));
flushSync();
expect(calls).toEqual([]);
expect(drag.isDragging).toBe(false);
});
it('click after pointerup is suppressed (no double emit)', async () => {
const { opts, calls } = makeOpts({ indexFromClientX: vi.fn(() => 0) });
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 0);
document.dispatchEvent(new PointerEvent('pointerup'));
flushSync();
drag.click(0); // synthesized click after pointerup
expect(calls.length).toBe(1); // only the pointerup-driven emit, not the click
});
it('cleanup removes document pointer listeners', () => {
const removeSpy = vi.spyOn(document, 'removeEventListener');
const { opts } = makeOpts();
const drag = createTimelineDrag(opts);
drag.pointerDown(pointerDownEvent(0), 0);
drag.cleanup();
const removed = removeSpy.mock.calls.map((c) => c[0]);
expect(removed).toEqual(expect.arrayContaining(['pointermove', 'pointerup', 'pointercancel']));
});
});