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[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'])); }); });