Files
familienarchiv/frontend/src/lib/document/annotation/AnnotationShape.svelte.spec.ts
Marcel 6fd05e08d8 test(transcribe): prove Delete fires once via real shape + action (#327)
Review follow-up (Sara): the prior single-owner evidence was two separate
unit facts against an inert DOM stub. This renders a real AnnotationShape,
attaches the live transcribeShortcuts action, focuses the region, and presses
Delete once — asserting deleteCurrentRegion fires exactly once. A genuine
integration guard against re-introducing a double-bind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 17:54:24 +02:00

180 lines
5.8 KiB
TypeScript

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> = {}
): 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();
});
});