import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import AnnotationShape from './AnnotationShape.svelte'; import { transcribeShortcuts, type TranscribeShortcutOptions } from '$lib/shared/actions/transcribeShortcuts'; afterEach(cleanup); function noopShortcutOptions( overrides: Partial = {} ): TranscribeShortcutOptions { return { isPanelOpen: () => true, isCheatsheetOpen: () => false, panelMode: () => 'edit', goToNextRegion: () => {}, goToPrevRegion: () => {}, toggleMode: () => {}, closePanel: () => {}, startDrawMode: () => {}, toggleTrainingMark: () => {}, deleteCurrentRegion: () => {}, openCheatsheet: () => {}, ...overrides }; } function makeAnnotation(id = 'ann-1') { return { id, documentId: 'doc-1', pageNumber: 1, x: 0.1, y: 0.1, width: 0.3, height: 0.2, color: '#00C7B1', createdAt: new Date().toISOString() }; } describe('AnnotationShape', () => { it('renders the annotation element', async () => { render(AnnotationShape, { annotation: makeAnnotation(), isHovered: false, isActive: false, onclick: () => {}, onpointerenter: () => {}, onpointerleave: () => {} }); await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); }); // The on-canvas delete button was removed (issue #722) because it overlapped // the document text. Deletion now happens via the transcription panel or the // keyboard Delete shortcut. No visible delete button must ever render — even // when hovered and active in delete mode. it('never renders a delete button, even when hovered and active in delete mode', async () => { render(AnnotationShape, { annotation: makeAnnotation(), isHovered: true, isActive: true, showDelete: true, onclick: () => {}, onpointerenter: () => {}, onpointerleave: () => {} }); await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); // Positive control: the previously-removed testid must stay absent. await expect.element(page.getByTestId('annotation-delete-ann-1')).not.toBeInTheDocument(); // Real invariant: the annotation must contain no clickable delete control at all. const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement; expect(annotationEl.querySelectorAll('button').length).toBe(0); }); // Deletion is owned solely by the transcribeShortcuts action (issue #327, // decision: action is the single Delete owner). The shape must NOT handle // the Delete key itself, or the key would delete twice. it('does not act on the Delete key itself (the action owns deletion)', async () => { const onclick = vi.fn(); render(AnnotationShape, { annotation: makeAnnotation(), isHovered: false, isActive: true, showDelete: true, onclick, onpointerenter: () => {}, onpointerleave: () => {} }); const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement; annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); // No side effect from the shape; it stays in the document for the action to act on. expect(onclick).not.toHaveBeenCalled(); await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); }); it('announces the Delete affordance via aria when deletion is available', async () => { render(AnnotationShape, { annotation: makeAnnotation(), isHovered: false, isActive: true, showDelete: true, onclick: () => {}, onpointerenter: () => {}, onpointerleave: () => {} }); const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement; expect(annotationEl.getAttribute('aria-keyshortcuts')).toBe('Delete'); expect(annotationEl.getAttribute('aria-label')).toContain('Entf'); }); it('keeps the plain label and no key hint when deletion is unavailable', async () => { render(AnnotationShape, { annotation: makeAnnotation(), isHovered: false, isActive: false, showDelete: false, onclick: () => {}, onpointerenter: () => {}, onpointerleave: () => {} }); const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement; expect(annotationEl.getAttribute('aria-keyshortcuts')).toBe(null); expect(annotationEl.getAttribute('aria-label')).toBe('Block anzeigen'); }); it('calls onfocus when the annotation receives focus', async () => { const onfocus = vi.fn(); render(AnnotationShape, { annotation: makeAnnotation(), isHovered: false, isActive: false, onfocus, onclick: () => {}, onpointerenter: () => {}, onpointerleave: () => {} }); const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement; annotationEl.dispatchEvent(new FocusEvent('focus')); expect(onfocus).toHaveBeenCalledOnce(); }); // Integration: a real rendered shape + the live transcribeShortcuts action. // Pressing Delete on the focused region must delete exactly once — proving the // action is the single owner and the shape contributes no competing handler. it('with the transcribeShortcuts action active, Delete deletes the focused region exactly once', () => { const deleteCurrentRegion = vi.fn(); render(AnnotationShape, { annotation: makeAnnotation(), isHovered: false, isActive: true, showDelete: true, onclick: () => {}, onpointerenter: () => {}, onpointerleave: () => {} }); const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement; const action = transcribeShortcuts(annotationEl, noopShortcutOptions({ deleteCurrentRegion })); annotationEl.focus(); annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); expect(deleteCurrentRegion).toHaveBeenCalledTimes(1); action.destroy(); }); });