Felix #7: handleMentionClick unconditionally preventDefault'd and goto'd, breaking ctrl-click / cmd-click / shift-click / alt-click / middle-click — "open in new tab" is a real workflow for researchers comparing two persons. Add isPlainPrimaryClick() guard. Modified clicks fall through to the browser's default anchor handling (the <a href="/persons/{id}"> opens in the new tab as expected). Plain left-clicks still SPA-navigate via goto(). Three new tests assert ctrl-click, meta-click, and middle-click are not preventDefault'd. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
425 lines
13 KiB
TypeScript
425 lines
13 KiB
TypeScript
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 <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('&');
|
|
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: '<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('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');
|
|
});
|
|
});
|