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'; const blocks: TranscriptionBlockData[] = [ { id: 'b1', annotationId: 'ann-1', documentId: 'doc-1', text: 'First paragraph text.', label: null, sortOrder: 1, version: 1, source: 'MANUAL', reviewed: false, mentionedPersons: [] }, { id: 'b2', annotationId: 'ann-2', documentId: 'doc-1', text: 'Second paragraph text.', label: null, sortOrder: 2, version: 1, source: 'MANUAL', reviewed: false, mentionedPersons: [] } ]; describe('TranscriptionReadView', () => { it('should render one paragraph per block', async () => { render(TranscriptionReadView, { blocks, onParagraphClick: () => {} }); await expect.element(page.getByText('First paragraph text.')).toBeInTheDocument(); await expect.element(page.getByText('Second paragraph text.')).toBeInTheDocument(); const paragraphs = document.querySelectorAll('[data-block-id]'); expect(paragraphs.length).toBe(2); }); it('should render [unleserlich] as italic muted text', async () => { render(TranscriptionReadView, { blocks: [ { id: 'b1', annotationId: 'ann-1', documentId: 'doc-1', text: 'Text before [unleserlich] text after', label: null, sortOrder: 1, version: 1, source: 'MANUAL', reviewed: false, mentionedPersons: [] } ], onParagraphClick: () => {} }); const marker = document.querySelector('[data-marker]'); expect(marker).not.toBeNull(); expect(marker!.textContent).toBe('[unleserlich]'); expect(marker!.tagName.toLowerCase()).toBe('em'); }); it('should render [...] as italic muted text', async () => { render(TranscriptionReadView, { blocks: [ { id: 'b1', annotationId: 'ann-1', documentId: 'doc-1', text: 'Some [...] text', label: null, sortOrder: 1, version: 1, source: 'MANUAL', reviewed: false, mentionedPersons: [] } ], onParagraphClick: () => {} }); const marker = document.querySelector('[data-marker]'); expect(marker).not.toBeNull(); expect(marker!.textContent).toBe('[...]'); }); it('should call onParagraphClick with annotationId when paragraph is clicked', async () => { const onParagraphClick = vi.fn(); render(TranscriptionReadView, { blocks, onParagraphClick }); const paragraph = document.querySelector('[data-block-id="b1"]')!; paragraph.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(onParagraphClick).toHaveBeenCalledWith('ann-1'); }); it('should render blocks sorted by sortOrder', async () => { render(TranscriptionReadView, { blocks: [ { ...blocks[1], sortOrder: 1 }, { ...blocks[0], sortOrder: 2 } ], onParagraphClick: () => {} }); const paragraphs = document.querySelectorAll('[data-block-id]'); expect(paragraphs[0].getAttribute('data-block-id')).toBe('b2'); expect(paragraphs[1].getAttribute('data-block-id')).toBe('b1'); }); it('should apply flash-highlight class when highlightBlockId matches', async () => { render(TranscriptionReadView, { blocks: [blocks[0]], onParagraphClick: () => {}, highlightBlockId: 'b1' }); const el = document.querySelector('[data-block-id="b1"]')!; expect(el.classList.contains('flash-highlight')).toBe(true); }); it('should not apply flash-highlight class when highlightBlockId does not match', async () => { render(TranscriptionReadView, { blocks: [blocks[0]], onParagraphClick: () => {}, highlightBlockId: 'other-id' }); const el = document.querySelector('[data-block-id="b1"]')!; expect(el.classList.contains('flash-highlight')).toBe(false); }); it('should render empty state when no blocks', async () => { render(TranscriptionReadView, { blocks: [], onParagraphClick: () => {} }); const paragraphs = document.querySelectorAll('[data-block-id]'); 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 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('&'); expect(blockEl.textContent).not.toContain('<'); }); 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: '', mentionedPersons: [] }; render(TranscriptionReadView, { blocks: [xss], onParagraphClick: () => {} }); // No raw 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(''); }); 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('lets ctrl-click and meta-click fall through so users can open in a new tab', async () => { render(TranscriptionReadView, { blocks: [mentionBlock], onParagraphClick: () => {} }); const link = document.querySelector('a.person-mention')! as HTMLAnchorElement; // ctrl-click (Linux/Win "open in new tab") const ctrlClick = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }); const ctrlPrevented = !link.dispatchEvent(ctrlClick); expect(ctrlPrevented).toBe(false); // meta-click (macOS "open in new tab") const metaClick = new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true }); const metaPrevented = !link.dispatchEvent(metaClick); expect(metaPrevented).toBe(false); }); it('lets middle-click fall through so users can open in a background tab', async () => { render(TranscriptionReadView, { blocks: [mentionBlock], onParagraphClick: () => {} }); const link = document.querySelector('a.person-mention')! as HTMLAnchorElement; // button === 1 is middle mouse button const middleClick = new MouseEvent('click', { bubbles: true, cancelable: true, button: 1 }); const prevented = !link.dispatchEvent(middleClick); expect(prevented).toBe(false); }); 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'); }); });