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>
180 lines
5.8 KiB
TypeScript
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();
|
|
});
|
|
});
|