feat(person-mention): TranscriptionReadView wires hover card and click nav

Composes splitByMarkers + renderTranscriptionBody so [unleserlich]
markers render as <em data-marker> siblings of the mention anchor —
neither nested inside the other (B19b).

Hover card lifecycle on each .person-mention anchor:
  mouseenter → set aria-describedby, place card via getBoundingClientRect
               (default below-right; flip up if <200px from bottom or
                mention is in bottom 30% of viewport; flip left if
                <300px from right), fire fetch, mount card with
                skeleton state
  resolved   → swap card to loaded state with person + family
                relationships (PARENT_OF / SPOUSE_OF / SIBLING_OF only)
  404        → degrade: mark anchor with data-person-deleted="true",
                unmount card, suppress future hovers/clicks
  network    → swap card to error state — link still navigates
  mouseleave → drop aria-describedby, unmount card

Per-page SvelteMap<personId, Promise> cache (B15.5) so a sweep across
N mentions of the same person fires the backend once. Click handler
calls goto() so SvelteKit handles routing without a full reload.

Event listeners are attached once per article via a Svelte action
because the anchor HTML is injected via {@html ...} and would not
receive declarative bindings. The eslint-disable comment mirrors
the rationale on CommentMessage.svelte:88-89.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 08:21:35 +02:00
parent c9c395eb59
commit 1fd38830fe
2 changed files with 428 additions and 11 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionReadView from './TranscriptionReadView.svelte';
import type { TranscriptionBlockData } from '$lib/types';
@@ -152,3 +152,241 @@ describe('TranscriptionReadView', () => {
expect(paragraphs.length).toBe(0);
});
});
describe('TranscriptionReadView — person-mention rendering', () => {
const PERSON_ID = '550e8400-e29b-41d4-a716-446655440000';
const mentionBlock: TranscriptionBlockData = {
id: 'b1',
annotationId: 'ann-1',
documentId: 'doc-1',
text: 'Brief an @Auguste Raddatz vom Mai',
label: null,
sortOrder: 1,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
};
beforeEach(() => {
// Default: any /api/persons/{id} call returns 404 unless a test overrides it.
// Tests that need loaded data stub fetch themselves.
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
});
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
it('renders a person mention as an anchor link with the person URL', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector(`a.person-mention[data-person-id="${PERSON_ID}"]`)!;
expect(link).not.toBeNull();
expect(link.getAttribute('href')).toBe(`/persons/${PERSON_ID}`);
expect(link.textContent).toBe('Auguste Raddatz');
});
it('strips the @ trigger from the rendered link text (read mode)', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const block = document.querySelector('[data-block-id="b1"]')!;
expect(block.textContent).not.toContain('@Auguste Raddatz');
expect(block.textContent).toContain('Auguste Raddatz');
});
it('renders mention link AND [unleserlich] marker correctly when both occur in the same block (B19b)', async () => {
const block: TranscriptionBlockData = {
...mentionBlock,
text: 'Hallo @Auguste Raddatz [unleserlich] Marie'
};
render(TranscriptionReadView, {
blocks: [block],
onParagraphClick: () => {}
});
// Mention rendered as an anchor
const link = document.querySelector('a.person-mention')!;
expect(link).not.toBeNull();
expect(link.textContent).toBe('Auguste Raddatz');
// Marker rendered as <em data-marker>
const marker = document.querySelector('[data-marker]')!;
expect(marker).not.toBeNull();
expect(marker.textContent).toBe('[unleserlich]');
// Marker text is NOT inside the anchor — they are siblings, not nested
expect(link.contains(marker)).toBe(false);
// No double-escape — text content reads cleanly
const blockEl = document.querySelector('[data-block-id="b1"]')!;
expect(blockEl.textContent).not.toContain('&amp;');
expect(blockEl.textContent).not.toContain('&lt;');
});
it('does not render mention link for plain text without the @ trigger', async () => {
const plain: TranscriptionBlockData = {
...mentionBlock,
text: 'Auguste Raddatz war hier',
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
};
render(TranscriptionReadView, {
blocks: [plain],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention');
expect(link).toBeNull();
});
it('escapes HTML in the block text — no stored XSS via raw text', async () => {
const xss: TranscriptionBlockData = {
...mentionBlock,
text: '<img src=x onerror=alert(1)>',
mentionedPersons: []
};
render(TranscriptionReadView, {
blocks: [xss],
onParagraphClick: () => {}
});
// No raw <img> tag in DOM
expect(document.querySelector('[data-block-id="b1"] img')).toBeNull();
// The escaped text is visible
const block = document.querySelector('[data-block-id="b1"]')!;
expect(block.textContent).toContain('<img src=x onerror=alert(1)>');
});
it('triggers fetch for the person on mention mouseenter (B15.5 cache, single call)', async () => {
const fetchMock = vi.fn().mockResolvedValue({
status: 404,
ok: false,
json: vi.fn()
});
vi.stubGlobal('fetch', fetchMock);
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
const personFetches = fetchMock.mock.calls.filter((c) =>
String(c[0]).includes(`/api/persons/${PERSON_ID}`)
);
expect(personFetches.length).toBeGreaterThanOrEqual(1);
});
it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => {
const fetchMock = vi.fn().mockResolvedValue({
status: 404,
ok: false,
json: vi.fn()
});
vi.stubGlobal('fetch', fetchMock);
// Two blocks both mention the same person
const block2: TranscriptionBlockData = { ...mentionBlock, id: 'b2', annotationId: 'ann-2' };
render(TranscriptionReadView, {
blocks: [mentionBlock, block2],
onParagraphClick: () => {}
});
const links = document.querySelectorAll('a.person-mention');
links.forEach((link) => link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })));
// Plus a re-hover on the first
links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
const personFetches = fetchMock.mock.calls.filter(
(c) => String(c[0]) === `/api/persons/${PERSON_ID}`
);
expect(personFetches.length).toBe(1);
});
it('mounts the hover card on mouseenter when the fetch loads', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((url: string) => {
if (url.endsWith('/relationships')) {
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
}
return Promise.resolve({
status: 200,
ok: true,
json: () =>
Promise.resolve({
id: PERSON_ID,
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
personType: 'PERSON',
familyMember: true,
birthYear: 1882,
deathYear: 1944
})
});
})
);
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).not.toBeNull();
});
it('unmounts the hover card on mouseleave', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 5));
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
await new Promise((r) => setTimeout(r, 5));
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).toBeNull();
});
it('degrades to plain unlinked text when the person fetch returns 404', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
// 404 → no card mounted
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).toBeNull();
// Anchor is marked as deleted so subsequent hovers/clicks treat it as plain text
const stillLink = document.querySelector('a.person-mention')!;
expect(stillLink.getAttribute('data-person-deleted')).toBe('true');
});
});