diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 458412f8..d6b62635 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -11,6 +11,7 @@ bun.lockb # Build artifacts /.svelte-kit/ /.svelte-kit-backup/ +/.svelte-kit.old/ # Generated files /.svelte-kit-backup/ diff --git a/frontend/e2e/person-mention-read.spec.ts b/frontend/e2e/person-mention-read.spec.ts new file mode 100644 index 00000000..17b23c7a --- /dev/null +++ b/frontend/e2e/person-mention-read.spec.ts @@ -0,0 +1,163 @@ +import { test, expect, devices } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); +const STORAGE_STATE = path.resolve(__dirname, '.auth/user.json'); + +/** + * E2E for issue #362 — Person @mentions, read-mode rendering + hover card (B20/B21). + * + * Strategy: + * - Create a document, a Person, and a transcription block whose text contains + * `@DisplayName` and whose mentionedPersons sidecar links to that person. + * - Open the document in read mode. + * - B20: page.hover() on the .person-mention link → hover card mounts. + * - B21: with context.setHasTouch(true), page.tap() on the link → navigates + * to /persons/{id} without ever showing the hover card. + */ + +let docId: string; +let personId: string; +let docHref: string; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Person mention — read mode', () => { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + // 1. Person we will mention. + const personRes = await request.post('/api/persons', { + data: { + firstName: 'Auguste', + lastName: 'Raddatz', + personType: 'PERSON', + birthYear: 1882, + deathYear: 1944 + } + }); + if (!personRes.ok()) throw new Error(`Create person failed: ${personRes.status()}`); + const person = await personRes.json(); + personId = person.id; + + // 2. Document with a PDF so the transcription panel is mountable. + // Sara #3: timestamp the title so a previous run that crashed in beforeAll + // (and therefore skipped afterAll cleanup) cannot collide with this one. + const uniqueSuffix = Date.now(); + const docRes = await request.post('/api/documents', { + multipart: { + title: `E2E Person Mention Read ${uniqueSuffix}`, + documentDate: '1945-05-08' + } + }); + if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`); + const doc = await docRes.json(); + docId = doc.id; + docHref = `${baseURL}/documents/${docId}`; + + await request.put(`/api/documents/${docId}`, { + multipart: { + title: doc.title as string, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + + // 3. Annotation to anchor the block on the page. + const annRes = await request.post(`/api/documents/${docId}/annotations`, { + data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.5, height: 0.1, color: '#00C7B1' } + }); + if (!annRes.ok()) throw new Error(`Create annotation failed: ${annRes.status()}`); + + // 4. Block text contains @Auguste Raddatz; sidecar links it to personId. + const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.1, + width: 0.5, + height: 0.1, + text: 'Brief an @Auguste Raddatz vom Mai 1944', + label: null, + mentionedPersons: [{ personId, displayName: 'Auguste Raddatz' }] + } + }); + if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`); + }); + + test.afterAll(async ({ request }) => { + if (docId) await request.delete(`/api/documents/${docId}`); + if (personId) await request.delete(`/api/persons/${personId}`); + }); + + test('renders the @mention as an underlined anchor link to /persons/{id}', async ({ page }) => { + await page.goto(docHref); + await page.getByRole('button', { name: 'Transkription' }).click(); + + const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first(); + await expect(link).toBeVisible({ timeout: 5000 }); + await expect(link).toHaveAttribute('href', `/persons/${personId}`); + // The @ trigger is stripped from the rendered text per spec + await expect(link).toHaveText('Auguste Raddatz'); + }); + + test('B20: desktop hover mounts the hover card with loaded person data', async ({ page }) => { + await page.goto(docHref); + await page.getByRole('button', { name: 'Transkription' }).click(); + + const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first(); + await link.hover(); + + const card = page.getByTestId('person-hover-card'); + await expect(card).toBeVisible({ timeout: 5000 }); + // Loaded state: person displayName is rendered inside the card + await expect(page.getByTestId('person-hover-card-name')).toHaveText('Auguste Raddatz'); + // Footer link points to /persons/{id} + await expect(card.locator(`a[href="/persons/${personId}"]`)).toBeVisible(); + }); + + test('B20: hover card unmounts on mouseleave', async ({ page }) => { + await page.goto(docHref); + await page.getByRole('button', { name: 'Transkription' }).click(); + + const link = page.locator(`a.person-mention[data-person-id="${personId}"]`).first(); + await link.hover(); + await expect(page.getByTestId('person-hover-card')).toBeVisible(); + + // Move pointer away + await page.mouse.move(0, 0); + await expect(page.getByTestId('person-hover-card')).toBeHidden({ timeout: 2000 }); + }); + + test('B21: touch-device tap navigates without showing the hover card', async ({ browser }) => { + const context = await browser.newContext({ + ...devices['Pixel 7'], + storageState: STORAGE_STATE + }); + const touchPage = await context.newPage(); + try { + await touchPage.goto(docHref); + await touchPage.getByRole('button', { name: 'Transkription' }).click(); + + const link = touchPage.locator(`a.person-mention[data-person-id="${personId}"]`).first(); + await expect(link).toBeVisible({ timeout: 5000 }); + + // Sara #2: assert no card *before* the tap so the test actually proves + // the touch device suppression worked, not just that we navigated away. + await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0); + + await link.tap(); + // The card never mounted — the tap navigated directly per spec. + await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`)); + } finally { + await context.close(); + } + }); +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 3de63ff2..e2312f71 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -12,7 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); export default defineConfig( includeIgnoreFile(gitignorePath), - { ignores: ['src/paraglide/**'] }, + { ignores: ['src/paraglide/**', '.svelte-kit.old/**'] }, js.configs.recommended, ...ts.configs.recommended, ...svelte.configs.recommended, diff --git a/frontend/messages/de.json b/frontend/messages/de.json index f1b189ce..8ef11f81 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -423,6 +423,7 @@ "person_mention_open_link": "Zur Person", "person_mention_hover_hint": "Klick öffnet Seite", "person_mention_load_error": "Person konnte nicht geladen werden.", + "person_mention_loading": "Lade Person…", "person_mention_popup_empty": "Keine Personen gefunden", "person_mention_btn_label": "Person verlinken", "person_mention_create_new": "Neue Person anlegen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 02111802..c0909263 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -423,6 +423,7 @@ "person_mention_open_link": "Open person", "person_mention_hover_hint": "Click opens the page", "person_mention_load_error": "Could not load person.", + "person_mention_loading": "Loading person…", "person_mention_popup_empty": "No persons found", "person_mention_btn_label": "Link person", "person_mention_create_new": "Create new person", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 5d416017..4b2fcdaf 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -423,6 +423,7 @@ "person_mention_open_link": "Ir a la persona", "person_mention_hover_hint": "Clic abre la página", "person_mention_load_error": "No se pudo cargar la persona.", + "person_mention_loading": "Cargando persona…", "person_mention_popup_empty": "No se encontraron personas", "person_mention_btn_label": "Vincular persona", "person_mention_create_new": "Crear nueva persona", diff --git a/frontend/src/lib/components/PersonHoverCard.svelte b/frontend/src/lib/components/PersonHoverCard.svelte new file mode 100644 index 00000000..36a4989d --- /dev/null +++ b/frontend/src/lib/components/PersonHoverCard.svelte @@ -0,0 +1,270 @@ + + +
+ {#if state.status === 'loading'} +
+
+
+
+
+ {:else if state.status === 'error'} +
+ {m.person_mention_load_error()} +
+ + {:else} +
+
+
{state.person.displayName}
+ {#if dateRange} +
{dateRange}
+ {/if} + {#if state.person.alias} +
geb. {state.person.alias}
+ {/if} +
+ {#if familyChips.length > 0} +
+ {#each familyChips as chip (chip.id)} + {chip.relatedPersonDisplayName} + {/each} +
+ {/if} + {#if notesExcerpt} +

{notesExcerpt}

+ {/if} + +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts new file mode 100644 index 00000000..f68bfdfc --- /dev/null +++ b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonHoverCard from './PersonHoverCard.svelte'; +import type { components } from '$lib/generated/api'; + +type Person = components['schemas']['Person']; +type RelationshipDTO = components['schemas']['RelationshipDTO']; + +const AUGUSTE: Person = { + id: 'p-aug', + firstName: 'Auguste', + lastName: 'Raddatz', + displayName: 'Auguste Raddatz', + personType: 'PERSON', + familyMember: true, + birthYear: 1882, + deathYear: 1944 +} as unknown as Person; + +const POSITION = { top: 100, left: 200 }; + +afterEach(() => cleanup()); + +describe('PersonHoverCard — loading state', () => { + it('shows the skeleton when state.status is loading', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + await expect.element(page.getByTestId('person-hover-card-skeleton')).toBeInTheDocument(); + }); + + it('renders three skeleton bars', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + const bars = document.querySelectorAll('[data-testid="person-hover-card-skeleton"] .bar'); + expect(bars.length).toBe(3); + }); +}); + +describe('PersonHoverCard — error state', () => { + it('shows a generic error message when state.status is error', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'error' } + }); + await expect.element(page.getByTestId('person-hover-card-error')).toBeInTheDocument(); + }); + + it('still allows the link footer to navigate (link present in error state)', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'error' } + }); + // The card root must show the footer link even when the body errored — + // click navigation works regardless of fetch outcome. + const link = document.querySelector('a[href="/persons/p-aug"]'); + expect(link).not.toBeNull(); + }); +}); + +describe('PersonHoverCard — loaded state', () => { + it('renders the person displayName as the header name', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); + + it('renders the life-date range when birthYear and deathYear are present', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); + }); + + it('omits the life-date line when both years are missing', async () => { + const noDates = { ...AUGUSTE, birthYear: undefined, deathYear: undefined } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: noDates, relationships: [] } + }); + const dates = document.querySelector('[data-testid="person-hover-card-dates"]'); + expect(dates).toBeNull(); + }); + + it('renders "geb. " when alias is set', async () => { + const withAlias = { ...AUGUSTE, alias: 'Müller' } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: withAlias, relationships: [] } + }); + await expect.element(page.getByText('geb. Müller')).toBeInTheDocument(); + }); + + it('omits the maiden name line when alias is null', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const maiden = document.querySelector('[data-testid="person-hover-card-maiden"]'); + expect(maiden).toBeNull(); + }); + + it('renders family relationship chips for PARENT_OF, SPOUSE_OF, SIBLING_OF only', async () => { + const relationships: RelationshipDTO[] = [ + { + id: 'r1', + personId: 'p-aug', + relatedPersonId: 'p-spouse', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Otto Raddatz', + relationType: 'SPOUSE_OF' + }, + { + id: 'r2', + personId: 'p-aug', + relatedPersonId: 'p-friend', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Karl Friend', + relationType: 'FRIEND' + }, + { + id: 'r3', + personId: 'p-aug', + relatedPersonId: 'p-sibling', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Marie Sister', + relationType: 'SIBLING_OF' + } + ]; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships } + }); + await expect.element(page.getByText('Otto Raddatz')).toBeInTheDocument(); + await expect.element(page.getByText('Marie Sister')).toBeInTheDocument(); + // Non-family relationship type must be filtered out + const friendChip = page.getByText('Karl Friend'); + await expect.element(friendChip).not.toBeInTheDocument(); + }); + + it('omits the chips section entirely when no family relationships', async () => { + const onlyFriend: RelationshipDTO[] = [ + { + id: 'r1', + personId: 'p-aug', + relatedPersonId: 'p-friend', + personDisplayName: 'Auguste', + relatedPersonDisplayName: 'Karl Friend', + relationType: 'FRIEND' + } + ]; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: onlyFriend } + }); + const chips = document.querySelector('[data-testid="person-hover-card-chips"]'); + expect(chips).toBeNull(); + }); + + it('renders notes excerpt unchanged when notes ≤ 120 characters', async () => { + const withNotes = { ...AUGUSTE, notes: 'Born in Berlin.' } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: withNotes, relationships: [] } + }); + await expect.element(page.getByText('Born in Berlin.')).toBeInTheDocument(); + }); + + it('truncates notes longer than 120 characters with an ellipsis (single long word)', async () => { + // Single 150-char word with no spaces: word-boundary cut would yield nothing, + // so fall back to a hard cut at 120 + ellipsis (Sara #7: pin the exact length). + const long = 'x'.repeat(150); + const withLongNotes = { ...AUGUSTE, notes: long } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: withLongNotes, relationships: [] } + }); + const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!; + expect(notes.textContent).toBe('x'.repeat(120) + '…'); + }); + + it('truncates at the last word boundary inside the 120-char window (Leonie FINDING-04)', async () => { + // 150-char string with spaces — must cut at the last space, not mid-word. + const sentence = 'Sie war eine bekannte Schriftstellerin und engagierte sich '.repeat(3); + // length is 180, last space at idx ≤120 + const withLongNotes = { ...AUGUSTE, notes: sentence } as Person; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: withLongNotes, relationships: [] } + }); + const notes = document.querySelector('[data-testid="person-hover-card-notes"]')!; + const text = notes.textContent ?? ''; + // Ends with ellipsis + expect(text.endsWith('…')).toBe(true); + // Last char before the ellipsis is NOT a half-word — verify by checking that + // the position right before … is the end of a word (i.e., there's no letter + // further along in the original text immediately after our cut point). + const cut = text.slice(0, -1); // strip the … + // Find this cut substring in the original sentence + const idx = sentence.indexOf(cut); + expect(idx).toBe(0); + const charAfterCut = sentence[cut.length]; + // The next char should be a space — confirming we cut on a boundary + expect(charAfterCut).toBe(' '); + }); + + it('omits notes section when notes is null', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const notes = document.querySelector('[data-testid="person-hover-card-notes"]'); + expect(notes).toBeNull(); + }); + + it('footer renders an anchor link to /persons/{personId}', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const link = document.querySelector('a[href="/persons/p-aug"]')!; + expect(link).not.toBeNull(); + }); +}); + +describe('PersonHoverCard — accessibility', () => { + it('uses aria-live="polite" so screen readers announce loaded content', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + expect(root.getAttribute('aria-live')).toBe('polite'); + }); + + it('sets aria-busy="true" while loading so SR announces the state change on load', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + expect(root.getAttribute('aria-busy')).toBe('true'); + }); + + it('does not set aria-busy when loaded (so the loaded content is announced)', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + // aria-busy is either absent or "false" + const busy = root.getAttribute('aria-busy'); + expect(busy === null || busy === 'false').toBe(true); + }); + + it('names the region with the person displayName when loaded (WCAG 1.3.1)', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships: [] } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + expect(root.getAttribute('aria-label')).toBe('Auguste Raddatz'); + }); + + it('names the region with a generic loading label while loading', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + // Region must have an accessible name in every state — axe-core flags + // role="region" without aria-label / aria-labelledby. + expect(root.getAttribute('aria-label')).toBeTruthy(); + }); + + it('exposes the cardId as the host element id (so anchor aria-describedby works)', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-xyz', + position: POSITION, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]')!; + expect(root.id).toBe('card-xyz'); + }); + + it('positions itself absolutely at the given top/left', async () => { + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: { top: 333, left: 444 }, + state: { status: 'loading' } + }); + const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement; + expect(root.style.top).toBe('333px'); + expect(root.style.left).toBe('444px'); + expect(root.style.position).toBe('absolute'); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte index f1586e6c..4a3ac949 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -1,6 +1,20 @@ -
+
{#each sorted as block (block.id)}
a.sortOrder - b.sortOrder)); onclick={() => onParagraphClick(block.annotationId)} role="button" tabindex="0" - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId); }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId); + }} > - {#each splitByMarkers(block.text) as segment, i (i)} - {#if segment.type === 'marker'} - {segment.text} - {:else} - {segment.text} - {/if} - {/each} + + {@html renderBlockHtml(block)}
{/each}
+{#if activeCard} + +{/if} +