import { test, expect } from '@playwright/test'; test.describe('Person list', () => { test.beforeEach(async ({ page }) => { await page.goto('/persons'); }); test('renders the persons list page', async ({ page }) => { await expect(page.getByRole('heading', { name: /Personen/i })).toBeVisible(); await expect(page.getByRole('link', { name: /Neue Person/i })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/persons-list.png' }); }); test('search filters the persons list', async ({ page }) => { // Navigate directly with the query param — tests that search results are filtered // correctly without depending on the debounced oninput → goto chain in CI. await page.goto('/persons?q=zzz_unlikely_match'); await expect(page.getByText(/Keine Personen gefunden/i)).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/persons-search-empty.png' }); }); test('clicking a person opens the detail page', async ({ page }) => { const firstPerson = page.locator('a[href^="/persons/"]').first(); await firstPerson.click(); await expect(page).toHaveURL(/\/persons\/.+/); await page.screenshot({ path: 'test-results/e2e/person-detail.png' }); }); }); test.describe('Person detail', () => { test('shows the person name and their documents', async ({ page }) => { await page.goto('/persons'); const firstPerson = page.locator('a[href^="/persons/"]').first(); await firstPerson.click(); // The detail page shows the person's name as the top-level heading await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-detail-documents.png' }); }); test('can enter and cancel edit mode', async ({ page }) => { await page.goto('/persons'); const firstPerson = page.locator('a[href^="/persons/"]').first(); await firstPerson.click(); // Click the edit button const editBtn = page.getByRole('button', { name: /Bearbeiten/i }); if (await editBtn.isVisible()) { await editBtn.click(); await expect(page.getByLabel('Vorname')).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-edit-form.png' }); // Cancel await page.getByRole('button', { name: /Abbrechen/i }).click(); await expect(page.getByLabel('Vorname')).not.toBeVisible(); } }); test('birth and death year fields appear in edit mode and save correctly', async ({ page }) => { await page.goto('/persons'); const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first(); await firstPerson.click(); await page.waitForSelector('[data-hydrated]'); const editBtn = page.getByRole('button', { name: /Bearbeiten/i }); await editBtn.click(); await expect(page.getByLabel(/Geburtsjahr/i)).toBeVisible(); await expect(page.getByLabel(/Todesjahr/i)).toBeVisible(); await page.getByLabel(/Geburtsjahr/i).fill('1890'); await page.getByLabel(/Todesjahr/i).fill('1965'); await page.getByRole('button', { name: /Speichern/i }).click(); // After saving, the years should be shown in view mode await expect(page.getByText('* 1890')).toBeVisible(); await expect(page.getByText('† 1965')).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-birth-death-years.png' }); }); }); test.describe('New person', () => { test('renders the new person form', async ({ page }) => { await page.goto('/persons/new'); await expect(page.getByLabel('Vorname')).toBeVisible(); await expect(page.getByLabel('Nachname')).toBeVisible(); await expect(page.getByRole('button', { name: /Erstellen/i })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-new.png' }); }); test('shows a validation error when submitting with empty fields', async ({ page }) => { await page.goto('/persons/new'); // HTML required attribute prevents submission without filling required fields await page.getByRole('button', { name: /Erstellen/i }).click(); // The form should not have navigated away await expect(page).toHaveURL('/persons/new'); }); }); test.describe('Person creation', () => { test('user fills in first and last name and lands on the new person detail page', async ({ page }) => { await page.goto('/persons/new'); await page.getByLabel('Vorname').fill('E2E'); await page.getByLabel('Nachname').fill('Testperson'); await page.getByRole('button', { name: /Erstellen/i }).click(); await expect(page).toHaveURL(/\/persons\/[^/]+$/); await expect(page.getByRole('heading', { name: 'E2E Testperson' })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-create.png' }); }); }); test.describe('Person detail — sort toggle', () => { test('each section has its own sort toggle that works independently', async ({ page }) => { await page.goto('/persons'); const firstPerson = page.locator('a[href^="/persons/"]').first(); await firstPerson.click(); await page.waitForSelector('[data-hydrated]'); // Find sort buttons — there may be 0, 1 or 2 depending on whether sections have >1 doc const sortBtns = page.getByRole('button', { name: /Neueste zuerst|Älteste zuerst/i }); const btnCount = await sortBtns.count(); if (btnCount >= 1) { const firstBtn = sortBtns.first(); await expect(firstBtn).toContainText('Neueste zuerst'); await firstBtn.click(); await expect(firstBtn).toContainText('Älteste zuerst'); await firstBtn.click(); await expect(firstBtn).toContainText('Neueste zuerst'); } if (btnCount >= 2) { // Second sort button toggles independently const secondBtn = sortBtns.nth(1); await expect(secondBtn).toContainText('Neueste zuerst'); await secondBtn.click(); await expect(secondBtn).toContainText('Älteste zuerst'); // First button should be unaffected await expect(sortBtns.first()).toContainText('Neueste zuerst'); } await page.screenshot({ path: 'test-results/e2e/person-sort-toggle.png' }); }); }); test.describe('Person detail — sent and received documents', () => { test('shows both sent and received document sections', async ({ page }) => { await page.goto('/persons'); const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first(); await firstPerson.click(); await page.waitForSelector('[data-hydrated]'); await expect(page.getByRole('heading', { name: /Gesendete Dokumente/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /Empfangene Dokumente/i })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-sent-received.png' }); }); test('shows year range next to document count when documents have dates', async ({ page }) => { // Navigate to the first person who has documents with dates await page.goto('/persons'); const personLinks = page.locator('a[href^="/persons/"]:not([href="/persons/new"])'); const count = await personLinks.count(); for (let i = 0; i < count; i++) { await page.goto('/persons'); await personLinks.nth(i).click(); await page.waitForSelector('[data-hydrated]'); // Check if either section heading has a year range (4 digits) const sentHeading = page.getByRole('heading', { name: /Gesendete Dokumente/i }).locator('..'); const hasYearRange = await sentHeading.locator('span').filter({ hasText: /\d{4}/ }).count(); if (hasYearRange > 0) { await expect( sentHeading.locator('span').filter({ hasText: /\d{4}/ }).first() ).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/person-year-range.png' }); return; } } // If no person has dated documents, the test is a no-op (year range is optional) }); }); test.describe('Person detail — conversations link', () => { test('co-correspondent chips link to conversations pre-filled with both persons', async ({ page }) => { await page.goto('/persons'); const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first(); const href = await firstLink.getAttribute('href'); const personId = href!.split('/persons/')[1]; await firstLink.click(); await page.waitForSelector('[data-hydrated]'); // Co-correspondent chips link to /conversations?senderId=X&receiverId=Y const chip = page.locator(`a[href^="/conversations?senderId=${personId}&receiverId="]`).first(); if ((await chip.count()) > 0) { const chipHref = await chip.getAttribute('href'); expect(chipHref).toMatch(/\/conversations\?senderId=.+&receiverId=.+/); } }); }); test.describe('Conversations', () => { test('shows the empty state when no persons are selected', async ({ page }) => { await page.goto('/conversations'); await expect(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/conversations-empty.png' }); }); test('nav link is active on the conversations page', async ({ page }) => { await page.goto('/conversations'); const navLink = page.getByRole('link', { name: 'Konversationen' }); await expect(navLink).toHaveClass(/bg-nav-active/); }); test('sort toggle changes the button label', async ({ page }) => { await page.goto('/conversations'); await page.waitForSelector('[data-hydrated]'); const btn = page.getByRole('button', { name: /Sortierung/i }); await expect(btn).toContainText('Neueste zuerst'); await btn.click(); await expect(page).toHaveURL(/dir=ASC/); await expect(btn).toContainText('Älteste zuerst'); await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' }); }); }); test.describe('Conversations — enhancements', () => { // Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer // Navigate directly by URL so the test doesn't rely on typeahead interaction async function loadHansAnnaConversation(page: import('@playwright/test').Page) { // Resolve person IDs from the persons list await page.goto('/persons'); const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first(); const hansHref = await hansLink.getAttribute('href'); const hansId = hansHref!.split('/').pop()!; const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first(); const annaHref = await annaLink.getAttribute('href'); const annaId = annaHref!.split('/').pop()!; await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`); await page.waitForURL(/senderId=/); } test('shows document count and year range summary when both persons are selected', async ({ page }) => { await loadHansAnnaConversation(page); // Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 1923–1965 await expect(page.getByTestId('conv-summary')).toContainText('2'); await expect(page.getByTestId('conv-summary')).toContainText('1923'); await expect(page.getByTestId('conv-summary')).toContainText('1965'); await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' }); }); test('shows year dividers between documents from different years', async ({ page }) => { await loadHansAnnaConversation(page); // Expect at least two year dividers (1923 and 1965) await expect(page.getByTestId('year-divider').first()).toBeVisible(); const dividers = page.getByTestId('year-divider'); const texts = await dividers.allTextContents(); expect(texts.some((t) => t.includes('1923'))).toBe(true); expect(texts.some((t) => t.includes('1965'))).toBe(true); await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' }); }); test('swap button switches sender and receiver and reloads', async ({ page }) => { await loadHansAnnaConversation(page); const url = new URL(page.url()); const originalSenderId = url.searchParams.get('senderId')!; const originalReceiverId = url.searchParams.get('receiverId')!; await page.getByTestId('conv-swap-btn').click(); // Wait for the URL to reflect the swapped IDs (not just any URL with senderId=) await page.waitForURL( (url) => new URL(url).searchParams.get('senderId') === originalReceiverId ); const swappedUrl = new URL(page.url()); expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId); expect(swappedUrl.searchParams.get('receiverId')).toBe(originalSenderId); await page.screenshot({ path: 'test-results/e2e/conversations-swap.png' }); }); test('shows "new document" link pre-filled with both persons when conversation is loaded', async ({ page }) => { await loadHansAnnaConversation(page); const url = new URL(page.url()); const senderId = url.searchParams.get('senderId')!; const receiverId = url.searchParams.get('receiverId')!; const link = page.getByTestId('conv-new-doc-link'); await expect(link).toBeVisible(); const href = await link.getAttribute('href'); expect(href).toContain(`senderId=${senderId}`); expect(href).toContain(`receiverId=${receiverId}`); await page.screenshot({ path: 'test-results/e2e/conversations-new-doc-link.png' }); }); test('does not show swap button or new document link when only one person is selected', async ({ page }) => { await page.goto('/conversations'); await page.waitForURL('/conversations'); await expect(page.getByTestId('conv-swap-btn')).not.toBeVisible(); await expect(page.getByTestId('conv-new-doc-link')).not.toBeVisible(); }); });