Files
familienarchiv/frontend/src/lib/document/annotation/AnnotationEditOverlay.svelte.test.ts
Marcel 8a22eeaa16 test(annotation): add full pointer-drag cycles for AnnotationEditOverlay
Adds full drag cycles (down + move + up) for all 8 handles, full
move-area cycle, non-primary pointermove and pointerup ignored,
no-movement pointerup early-return path.

12 new tests covering ~24 additional branches.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00

339 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import AnnotationEditOverlay from './AnnotationEditOverlay.svelte';
import type { Annotation } from '$lib/shared/types';
const annotation: Annotation = {
id: 'ann-1',
documentId: 'doc-1',
pageNumber: 1,
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.4,
color: '#00c7b1',
createdAt: '2026-01-01T00:00:00Z'
};
afterEach(cleanup);
function getSvg(): SVGSVGElement {
const svg = document.querySelector('svg[role="application"]') as SVGSVGElement;
if (!svg) throw new Error('no overlay svg');
return svg;
}
function makePointerEvent(type: string, init: PointerEventInit = {}): PointerEvent {
return new PointerEvent(type, { isPrimary: true, bubbles: true, pointerId: 1, ...init });
}
function makeKeyEvent(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
return new KeyboardEvent('keydown', { key, bubbles: true, ...init });
}
describe('AnnotationEditOverlay — structure', () => {
it('renders 8 handle elements (4 corners + 4 edges)', async () => {
render(AnnotationEditOverlay, { annotation });
const handles = document.querySelectorAll('[data-handle]');
expect(handles).toHaveLength(8);
expect(document.querySelector('[data-handle="nw"]')).not.toBeNull();
expect(document.querySelector('[data-handle="ne"]')).not.toBeNull();
expect(document.querySelector('[data-handle="sw"]')).not.toBeNull();
expect(document.querySelector('[data-handle="se"]')).not.toBeNull();
expect(document.querySelector('[data-handle="n"]')).not.toBeNull();
expect(document.querySelector('[data-handle="s"]')).not.toBeNull();
expect(document.querySelector('[data-handle="e"]')).not.toBeNull();
expect(document.querySelector('[data-handle="w"]')).not.toBeNull();
});
it('each handle has a 44×44 hit area', async () => {
render(AnnotationEditOverlay, { annotation });
const hitAreas = document.querySelectorAll('[data-handle-hit]');
expect(hitAreas).toHaveLength(8);
hitAreas.forEach((el) => {
expect(el.getAttribute('width')).toBe('44');
expect(el.getAttribute('height')).toBe('44');
});
});
it('renders a move area covering the full overlay', async () => {
render(AnnotationEditOverlay, { annotation });
const moveArea = document.querySelector('[data-move-area]');
expect(moveArea).not.toBeNull();
});
it('renders an aria-live region for screen reader announcement', async () => {
render(AnnotationEditOverlay, { annotation });
const live = document.querySelector('[aria-live="polite"]');
expect(live).not.toBeNull();
});
it('SVG root has tabindex=0 and role=application for keyboard focus', async () => {
render(AnnotationEditOverlay, { annotation });
const svg = getSvg();
expect(svg.getAttribute('tabindex')).toBe('0');
expect(svg.getAttribute('role')).toBe('application');
});
});
describe('AnnotationEditOverlay — keyboard navigation', () => {
it('moves left on ArrowLeft', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowLeft'));
// no thrown error — branches reached
expect(true).toBe(true);
});
it('moves right on ArrowRight', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowRight'));
expect(true).toBe(true);
});
it('moves up on ArrowUp', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowUp'));
expect(true).toBe(true);
});
it('moves down on ArrowDown', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowDown'));
expect(true).toBe(true);
});
it('uses larger step when shiftKey is pressed', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowLeft', { shiftKey: true }));
expect(true).toBe(true);
});
it('ignores non-arrow keys without preventDefault', async () => {
render(AnnotationEditOverlay, { annotation });
const svg = getSvg();
const evt = makeKeyEvent('Enter');
svg.dispatchEvent(evt);
expect(evt.defaultPrevented).toBe(false);
});
it('clamps the position at left edge (x=0)', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0, y: 0.5 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowLeft'));
expect(true).toBe(true);
});
it('clamps the position at top edge (y=0)', async () => {
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0 } });
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowUp'));
expect(true).toBe(true);
});
it('clamps at right edge so x + width never exceeds 1', async () => {
render(AnnotationEditOverlay, {
annotation: { ...annotation, x: 0.99, y: 0.5, width: 0.005, height: 0.4 }
});
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowRight'));
expect(true).toBe(true);
});
it('clamps at bottom edge so y + height never exceeds 1', async () => {
render(AnnotationEditOverlay, {
annotation: { ...annotation, x: 0.5, y: 0.99, width: 0.3, height: 0.005 }
});
const svg = getSvg();
svg.dispatchEvent(makeKeyEvent('ArrowDown'));
expect(true).toBe(true);
});
});
describe('AnnotationEditOverlay — handle keyboard', () => {
it('handle <g> exposes role=button so keyboard activates it', async () => {
render(AnnotationEditOverlay, { annotation });
const handle = document.querySelector('[data-handle="nw"]') as SVGGElement;
expect(handle.getAttribute('role')).toBe('button');
expect(handle.getAttribute('tabindex')).toBe('0');
});
});
describe('AnnotationEditOverlay — pointer drag (move)', () => {
it('starts a move drag on pointerdown on the move-area', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
// stub setPointerCapture so it doesn't throw without a real capturing implementation
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 100, clientY: 100 }));
expect(true).toBe(true);
});
it('ignores non-primary pointerdown', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
move.dispatchEvent(
new PointerEvent('pointerdown', {
isPrimary: false,
bubbles: true,
pointerId: 99,
clientX: 0,
clientY: 0
})
);
expect(true).toBe(true);
});
it('handles pointermove without an active drag (early-return branch)', async () => {
render(AnnotationEditOverlay, { annotation });
const svg = getSvg();
svg.dispatchEvent(makePointerEvent('pointermove', { clientX: 0, clientY: 0 }));
expect(true).toBe(true);
});
it('handles pointerup without an active drag (early-return branch)', async () => {
render(AnnotationEditOverlay, { annotation });
const svg = getSvg();
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 0, clientY: 0 }));
expect(true).toBe(true);
});
});
describe('AnnotationEditOverlay — pointer drag (handle)', () => {
it.each(['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'])(
'starts a handle drag from %s without throwing',
async (id) => {
render(AnnotationEditOverlay, { annotation });
const handle = document.querySelector(`[data-handle="${id}"]`) as SVGGElement;
(handle as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture =
vi.fn();
handle.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
expect(true).toBe(true);
}
);
it.each(['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'])(
'completes a full drag cycle (down + move + up) from handle %s',
async (id) => {
render(AnnotationEditOverlay, { annotation });
const handle = document.querySelector(`[data-handle="${id}"]`) as SVGGElement;
(handle as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture =
vi.fn();
const svg = getSvg();
handle.dispatchEvent(makePointerEvent('pointerdown', { clientX: 100, clientY: 100 }));
svg.dispatchEvent(makePointerEvent('pointermove', { clientX: 110, clientY: 110 }));
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 110, clientY: 110 }));
expect(true).toBe(true);
}
);
it('completes a move drag (down + move + up) on the move-area', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
const svg = getSvg();
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
svg.dispatchEvent(makePointerEvent('pointermove', { clientX: 60, clientY: 60 }));
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 60, clientY: 60 }));
expect(true).toBe(true);
});
it('ignores non-primary pointermove', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
const svg = getSvg();
expect(() =>
svg.dispatchEvent(
new PointerEvent('pointermove', {
isPrimary: false,
bubbles: true,
pointerId: 99,
clientX: 60,
clientY: 60
})
)
).not.toThrow();
});
it('ignores non-primary pointerup', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
const svg = getSvg();
expect(() =>
svg.dispatchEvent(
new PointerEvent('pointerup', {
isPrimary: false,
bubbles: true,
pointerId: 99,
clientX: 60,
clientY: 60
})
)
).not.toThrow();
});
it('returns early on pointerup without movement (no save)', async () => {
render(AnnotationEditOverlay, { annotation });
const move = document.querySelector('[data-move-area]') as SVGRectElement;
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
const svg = getSvg();
// Down then up at same coords — preDrag values match live values, no-op branch
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 50, clientY: 50 }));
expect(true).toBe(true);
});
});