diff --git a/frontend/e2e/person-mention-read.spec.ts b/frontend/e2e/person-mention-read.spec.ts new file mode 100644 index 00000000..b1a01bb4 --- /dev/null +++ b/frontend/e2e/person-mention-read.spec.ts @@ -0,0 +1,154 @@ +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. + const docRes = await request.post('/api/documents', { + multipart: { title: 'E2E Person Mention Read', 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, + 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 }); + + await link.tap(); + // The card never mounted — the tap navigated directly per spec + await expect(touchPage).toHaveURL(new RegExp(`/persons/${personId}`)); + await expect(touchPage.getByTestId('person-hover-card')).toHaveCount(0); + } finally { + await context.close(); + } + }); +});