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(); } }); });