Code: - Persist panelOpen to localStorage so panel stays open after reload - Auto-open panel to Metadaten when document has no file (no prior state) Tests: - Nav active state: check bg-nav-active instead of text-brand-navy (nav uses semantic tokens since dark mode refactor) - Save button: use exact:true to avoid matching "Speichern & abschließen" (new button was added alongside the plain "Speichern" button) Note: annotation tests (documents.spec.ts:324, 356) are pre-existing flaky failures due to test data contamination, not caused by this PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
313 lines
13 KiB
TypeScript
313 lines
13 KiB
TypeScript
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();
|
||
});
|
||
});
|