import { describe, it, expect } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import AnnotationLayer from './AnnotationLayer.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.1, color: '#00c7b1', createdAt: '2026-01-01T00:00:00Z' }; const polygonAnnotation: Annotation = { ...annotation, id: 'ann-poly', polygon: [ [0.1, 0.2], [0.4, 0.21], [0.39, 0.29], [0.11, 0.28] ] }; describe('AnnotationLayer', () => { describe('dimmed prop', () => { it('should hide block number badges when dimmed is true', async () => { render(AnnotationLayer, { annotations: [annotation], canDraw: false, color: '#00c7b1', blockNumbers: { 'ann-1': 1 }, dimmed: true, onDraw: () => {} }); const badge = page.getByText('1'); await expect.element(badge).not.toBeInTheDocument(); }); it('should show block number badges when dimmed is false', async () => { render(AnnotationLayer, { annotations: [annotation], canDraw: false, color: '#00c7b1', blockNumbers: { 'ann-1': 1 }, dimmed: false, onDraw: () => {} }); const badge = page.getByText('1'); await expect.element(badge).toBeInTheDocument(); }); it('should still fire onAnnotationClick when dimmed', async () => { let clickedId: string | undefined; render(AnnotationLayer, { annotations: [annotation], canDraw: false, color: '#00c7b1', dimmed: true, onDraw: () => {}, onAnnotationClick: (id: string) => { clickedId = id; } }); const el = document.querySelector('[data-testid="annotation-ann-1"]')!; el.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(clickedId).toBe('ann-1'); }); }); describe('isResizable computation', () => { it('passes isResizable=true when canDraw, annotation is active, and has no polygon', async () => { render(AnnotationLayer, { annotations: [annotation], canDraw: true, color: '#00c7b1', activeAnnotationId: 'ann-1', onDraw: () => {} }); const handles = document.querySelectorAll('[data-handle]'); expect(handles).toHaveLength(8); }); it('passes isResizable=false when annotation has a polygon', async () => { render(AnnotationLayer, { annotations: [polygonAnnotation], canDraw: true, color: '#00c7b1', activeAnnotationId: 'ann-poly', onDraw: () => {} }); const handles = document.querySelectorAll('[data-handle]'); expect(handles).toHaveLength(0); }); it('passes isResizable=false when canDraw is false', async () => { render(AnnotationLayer, { annotations: [annotation], canDraw: false, color: '#00c7b1', activeAnnotationId: 'ann-1', onDraw: () => {} }); const handles = document.querySelectorAll('[data-handle]'); expect(handles).toHaveLength(0); }); it('passes isResizable=false when annotation is not active', async () => { render(AnnotationLayer, { annotations: [annotation], canDraw: true, color: '#00c7b1', activeAnnotationId: 'other-id', onDraw: () => {} }); const handles = document.querySelectorAll('[data-handle]'); expect(handles).toHaveLength(0); }); }); describe('flashAnnotationId prop', () => { it('should apply annotation-flash class when flashAnnotationId matches', async () => { render(AnnotationLayer, { annotations: [annotation], canDraw: false, color: '#00c7b1', flashAnnotationId: 'ann-1', onDraw: () => {} }); const el = document.querySelector('[data-testid="annotation-ann-1"]')!; expect(el.classList.contains('annotation-flash')).toBe(true); }); it('should not apply annotation-flash class when flashAnnotationId does not match', async () => { render(AnnotationLayer, { annotations: [annotation], canDraw: false, color: '#00c7b1', flashAnnotationId: 'other-id', onDraw: () => {} }); const el = document.querySelector('[data-testid="annotation-ann-1"]')!; expect(el.classList.contains('annotation-flash')).toBe(false); }); }); describe('container style', () => { it('uses crosshair cursor when canDraw is true', async () => { render(AnnotationLayer, { annotations: [], canDraw: true, color: '#00c7b1', onDraw: () => {} }); const wrapper = document.querySelector('[role="presentation"]') as HTMLElement; expect(wrapper.style.cursor).toContain('crosshair'); expect(wrapper.style.touchAction).toBe('none'); }); it('omits crosshair cursor when canDraw is false', async () => { render(AnnotationLayer, { annotations: [], canDraw: false, color: '#00c7b1', onDraw: () => {} }); const wrapper = document.querySelector('[role="presentation"]') as HTMLElement; expect(wrapper.style.cursor).not.toContain('crosshair'); }); }); describe('annotation pointer hover', () => { it('updates hoveredId on pointerenter and clears on pointerleave', async () => { render(AnnotationLayer, { annotations: [annotation], canDraw: false, color: '#00c7b1', onDraw: () => {} }); const ann = document.querySelector('[data-testid="annotation-ann-1"]') as HTMLElement; ann.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true })); await new Promise((r) => setTimeout(r, 30)); ann.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true })); await new Promise((r) => setTimeout(r, 30)); // No throw is the assertion expect(true).toBe(true); }); it('renders both annotations with activeAnnotationId set', async () => { const second: Annotation = { ...annotation, id: 'ann-other', x: 0.5, y: 0.5 }; render(AnnotationLayer, { annotations: [annotation, second], canDraw: false, color: '#00c7b1', activeAnnotationId: 'ann-1', dimmed: false, onDraw: () => {} }); const otherEl = document.querySelector('[data-testid="annotation-ann-other"]'); const activeEl = document.querySelector('[data-testid="annotation-ann-1"]'); expect(otherEl).not.toBeNull(); expect(activeEl).not.toBeNull(); }); it('skips faded styling when dimmed is true (dimmed wins over faded)', async () => { const second: Annotation = { ...annotation, id: 'ann-other' }; render(AnnotationLayer, { annotations: [annotation, second], canDraw: false, color: '#00c7b1', activeAnnotationId: 'ann-1', dimmed: true, onDraw: () => {} }); // Dimmed mode: badge hidden but renders expect(document.querySelector('[data-testid="annotation-ann-1"]')).not.toBeNull(); }); it('renders without throwing when canDraw is true (delete button visible)', async () => { expect(() => render(AnnotationLayer, { annotations: [annotation], canDraw: true, color: '#00c7b1', onDraw: () => {} }) ).not.toThrow(); }); it('renders without throwing when blockNumbers map has entries', async () => { expect(() => render(AnnotationLayer, { annotations: [annotation], canDraw: false, color: '#00c7b1', blockNumbers: { 'ann-1': 5 }, onDraw: () => {} }) ).not.toThrow(); expect(document.body.textContent).toContain('5'); }); }); describe('drawing pointer flow', () => { it('does not start a draw when canDraw is false', async () => { render(AnnotationLayer, { annotations: [], canDraw: false, color: '#00c7b1', onDraw: () => {} }); const wrapper = document.querySelector('[role="presentation"]') as HTMLElement; (wrapper as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = () => {}; wrapper.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true, clientX: 50, clientY: 50, pointerId: 1 }) ); // No preview rect rendered const preview = wrapper.querySelector('div[style*="border: 2px dashed"]'); expect(preview).toBeNull(); }); it('does not start a draw when pointerdown lands on an existing annotation', async () => { render(AnnotationLayer, { annotations: [annotation], canDraw: true, color: '#00c7b1', onDraw: () => {} }); const ann = document.querySelector('[data-testid="annotation-ann-1"]') as HTMLElement; (ann as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = () => {}; // pointerdown bubbles to the layer; layer should refuse to draw because // closest('[data-annotation]') matches. ann.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true, clientX: 0, clientY: 0, pointerId: 1 }) ); const preview = document.querySelector('div[style*="border: 2px dashed"]'); expect(preview).toBeNull(); }); it('renders no preview rect when no draw is in progress', async () => { render(AnnotationLayer, { annotations: [], canDraw: true, color: '#00c7b1', onDraw: () => {} }); const preview = document.querySelector('div[style*="border: 2px dashed"]'); expect(preview).toBeNull(); }); it('handles pointermove without a started draw (early-return)', async () => { render(AnnotationLayer, { annotations: [], canDraw: true, color: '#00c7b1', onDraw: () => {} }); const wrapper = document.querySelector('[role="presentation"]') as HTMLElement; expect(() => wrapper.dispatchEvent( new PointerEvent('pointermove', { bubbles: true, clientX: 0, clientY: 0 }) ) ).not.toThrow(); }); it('handles pointerup without a started draw (early-return)', async () => { let drawn = false; render(AnnotationLayer, { annotations: [], canDraw: true, color: '#00c7b1', onDraw: () => { drawn = true; } }); const wrapper = document.querySelector('[role="presentation"]') as HTMLElement; wrapper.dispatchEvent( new PointerEvent('pointerup', { bubbles: true, clientX: 0, clientY: 0 }) ); expect(drawn).toBe(false); }); }); });