refactor(documents): extract timeline drag state into rune class (#385)
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
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
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #478.
This commit is contained in:
155
frontend/src/lib/document/useTimelineDrag.svelte.test.ts
Normal file
155
frontend/src/lib/document/useTimelineDrag.svelte.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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']));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user