Files
familienarchiv/frontend/src/lib/document/transcription/TranscriptionReadView.svelte.spec.ts
Marcel 567612761d refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/
- Move person relationship components to lib/person/relationship/
- Move Stammbaum components to lib/person/genealogy/
- Move HelpPopover to lib/shared/primitives/
- Update all import paths across routes, specs, and lib files
- Update vi.mock() paths in server-project test files
- Remove now-empty legacy directories (components/, hooks/, server/, etc.)
- Update vite.config.ts coverage include paths for new structure
- Update frontend/CLAUDE.md to reflect domain-based lib/ layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:53:31 +02:00

171 lines
5.5 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import TranscriptionReadView from './TranscriptionReadView.svelte';
import type { TranscriptionBlockData } from '$lib/shared/types';
const PERSON_ID = '11111111-0000-0000-0000-000000000001';
const block: TranscriptionBlockData = {
id: 'b1',
annotationId: 'a1',
documentId: 'd1',
text: '@Auguste',
label: null,
sortOrder: 0,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste' }]
};
function mockPersonFetch() {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((url: string) => {
if (url.includes('/relationships')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
}
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
id: PERSON_ID,
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz'
})
});
})
);
}
function getMentionLink(): HTMLAnchorElement {
return document.querySelector(
`a.person-mention[data-person-id="${PERSON_ID}"]`
) as HTMLAnchorElement;
}
function getHoverCard(): HTMLElement | null {
return document.querySelector('[data-testid="person-hover-card"]');
}
/** Hover a mention and wait until the loaded card content is in the DOM. */
async function showCard(): Promise<void> {
getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }));
await vi.waitFor(() => {
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
});
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// ─── Mouse timer behavior ──────────────────────────────────────────────────────
describe('TranscriptionReadView — hover card mouse timer', () => {
it('keeps the card open when mouse moves from mention to card within 150ms', async () => {
mockPersonFetch();
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
await showCard();
// Leave mention — starts 150ms close timer
getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }));
// Enter card before 150ms — cancels timer
getHoverCard()!.dispatchEvent(new MouseEvent('mouseenter'));
// Wait past the original 150ms window
await new Promise((r) => setTimeout(r, 200));
expect(getHoverCard()).not.toBeNull();
});
it('closes the card immediately when mouse leaves the card (no timer)', async () => {
mockPersonFetch();
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
await showCard();
// Leave card — activeCard = null immediately, no timer
getHoverCard()!.dispatchEvent(new MouseEvent('mouseleave'));
await vi.waitFor(() => {
expect(getHoverCard()).toBeNull();
});
});
it('cancels a pending close when mouse re-enters a mention', async () => {
mockPersonFetch();
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
await showCard();
// Leave mention — starts 150ms close timer
getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }));
// Re-enter same mention before 150ms — cancels timer
getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }));
// Wait past the original 150ms window
await new Promise((r) => setTimeout(r, 200));
expect(getHoverCard()).not.toBeNull();
});
});
// ─── Keyboard focus behavior ───────────────────────────────────────────────────
describe('TranscriptionReadView — hover card keyboard focus', () => {
it('keeps the card open when keyboard focus moves from mention into card', async () => {
mockPersonFetch();
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
// Show card via keyboard focusin on mention
getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await vi.waitFor(() => {
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
});
// Focus leaves mention — starts 150ms close timer
getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
// Focus enters card — should cancel the close timer
getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
// Wait past the 150ms window
await new Promise((r) => setTimeout(r, 200));
expect(getHoverCard()).not.toBeNull();
});
it('closes the card when keyboard focus leaves the card entirely', async () => {
mockPersonFetch();
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
// Show card via keyboard focusin
getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await vi.waitFor(() => {
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
});
// Focus leaves mention — 150ms timer starts
getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
// Focus enters card — cancels timer
getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
// Focus leaves card entirely (relatedTarget = null means focus left the page)
getHoverCard()!.dispatchEvent(
new FocusEvent('focusout', { bubbles: true, relatedTarget: null })
);
await vi.waitFor(() => {
expect(getHoverCard()).toBeNull();
});
});
});