## Pre-commit hook
- Add .husky/pre-commit at repo root: runs `cd frontend && npm run lint`
- Update prepare script in package.json to auto-configure git hooks path
on npm install (git -C .. config core.hooksPath .husky)
- Add lint step to CI unit-tests job so it catches issues before tests run
- Add generated dirs to .prettierignore (paraglide_bak*, test-results, .auth)
- Add src/lib/paraglide_bak* to .gitignore so ESLint can ignore them
## ESLint fixes (all pre-existing)
- Disable svelte/no-navigation-without-resolve: false positive in SvelteKit
(rule targets Svelte 5 standalone routing, not SvelteKit <a href>)
- Fix svelte/require-each-key: add (item.id)/(item) keys to all {#each} blocks
across 10 files — improves Svelte reconciliation performance
- Fix svelte/prefer-writable-derived in PersonTypeahead: $state+$effect → $derived
- Fix svelte/prefer-svelte-reactivity: URLSearchParams → SvelteURLSearchParams,
Map → SvelteMap (enables Svelte reactive tracking)
- Fix @typescript-eslint/no-unused-vars: remove dead imports/variables
## Prettier
- Run npm run format to bring all source files in line with .prettierrc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
214 lines
8.7 KiB
TypeScript
214 lines
8.7 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 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(/text-brand-navy/);
|
|
});
|
|
|
|
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' });
|
|
});
|
|
});
|