test(persons): cover PersonDocumentList and persons/new page

PersonDocumentList: empty/populated, year-range derivation across
no-date/single-year/multi-year inputs, sort toggle visibility (>1 doc),
sort-direction round trip, preview-limit + show-more expansion,
title→originalFilename fallback, no-date and no-location branches.

persons/new: PERSON vs INSTITUTION/GROUP visibility matrix
(firstName/alias/life-year fields toggle), lastName label switching
between Vorname/Nachname/Name, form-error banner, prior-form hydration,
cancel link href, fallback to PERSON for unknown personType.

24 tests across two files, hitting the 32+28 = 60 branches at the top
of the issue's leverage list.

Refs #496.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-09 20:18:34 +02:00
committed by marcel
parent fb52db1253
commit 1f3c18f898
2 changed files with 268 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonDocumentList from './PersonDocumentList.svelte';
afterEach(cleanup);
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
title: 'Brief an Helene',
originalFilename: 'brief.pdf',
documentDate: '1923-04-15',
location: 'Berlin',
status: 'UPLOADED' as string,
contentType: 'application/pdf',
thumbnailUrl: '',
...overrides
});
describe('PersonDocumentList', () => {
it('renders the heading and a count badge', async () => {
render(PersonDocumentList, {
props: { documents: [makeDoc()], heading: 'Gesendet', emptyMessage: 'Keine Dokumente' }
});
await expect.element(page.getByRole('heading', { name: /gesendet/i })).toBeVisible();
await expect.element(page.getByText('1', { exact: true })).toBeVisible();
});
it('renders the empty message when documents is an empty array', async () => {
render(PersonDocumentList, {
props: {
documents: [],
heading: 'Empfangen',
emptyMessage: 'Es liegen keine Dokumente vor.'
}
});
await expect.element(page.getByText('Es liegen keine Dokumente vor.')).toBeVisible();
});
it('hides the year range when no document has a date', async () => {
render(PersonDocumentList, {
props: {
documents: [makeDoc({ documentDate: null })],
heading: 'X',
emptyMessage: 'X'
}
});
await expect.element(page.getByText(/^\d{4}\s*\s*\d{4}$/)).not.toBeInTheDocument();
});
it('shows a single year when all documents fall in the same year', async () => {
render(PersonDocumentList, {
props: {
documents: [
makeDoc({ documentDate: '1923-01-01' }),
makeDoc({ id: 'd2', documentDate: '1923-12-30' })
],
heading: 'X',
emptyMessage: 'X'
}
});
await expect.element(page.getByText('1923', { exact: true })).toBeVisible();
});
it('shows a minmax range when documents span multiple years', async () => {
render(PersonDocumentList, {
props: {
documents: [
makeDoc({ id: 'd1', documentDate: '1899-01-01' }),
makeDoc({ id: 'd2', documentDate: '1923-12-31' })
],
heading: 'X',
emptyMessage: 'X'
}
});
await expect.element(page.getByText('1899 1923')).toBeVisible();
});
it('does not render the sort toggle when only one document is present', async () => {
render(PersonDocumentList, {
props: { documents: [makeDoc()], heading: 'X', emptyMessage: 'X' }
});
await expect
.element(page.getByRole('button', { name: /neueste zuerst|älteste zuerst/i }))
.not.toBeInTheDocument();
});
it('renders the sort toggle when at least two documents are present', async () => {
render(PersonDocumentList, {
props: {
documents: [makeDoc({ id: 'd1' }), makeDoc({ id: 'd2', documentDate: '1900-01-01' })],
heading: 'X',
emptyMessage: 'X'
}
});
await expect.element(page.getByRole('button', { name: /neueste zuerst/i })).toBeVisible();
});
it('toggles the sort direction label when the sort button is clicked', async () => {
render(PersonDocumentList, {
props: {
documents: [makeDoc({ id: 'd1' }), makeDoc({ id: 'd2', documentDate: '1900-01-01' })],
heading: 'X',
emptyMessage: 'X'
}
});
await page.getByRole('button', { name: /neueste zuerst/i }).click();
await expect.element(page.getByRole('button', { name: /älteste zuerst/i })).toBeVisible();
});
it('caps the visible documents at the preview limit and exposes a "show more" button', async () => {
const docs = Array.from({ length: 8 }, (_, i) =>
makeDoc({ id: `d${i}`, title: `Brief ${i + 1}` })
);
render(PersonDocumentList, {
props: { documents: docs, heading: 'X', emptyMessage: 'X' }
});
await expect.element(page.getByText('Brief 1')).toBeVisible();
await expect.element(page.getByText('Brief 6')).not.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /weitere anzeigen/i })).toBeVisible();
});
it('expands the list to all documents when the "show more" button is clicked', async () => {
const docs = Array.from({ length: 8 }, (_, i) =>
makeDoc({ id: `d${i}`, title: `Brief ${i + 1}` })
);
render(PersonDocumentList, {
props: { documents: docs, heading: 'X', emptyMessage: 'X' }
});
await page.getByRole('button', { name: /weitere anzeigen/i }).click();
await expect.element(page.getByText('Brief 6')).toBeVisible();
});
it('falls back to the originalFilename when title is missing', async () => {
render(PersonDocumentList, {
props: {
documents: [makeDoc({ title: null, originalFilename: 'untitled.pdf' })],
heading: 'X',
emptyMessage: 'X'
}
});
await expect.element(page.getByText('untitled.pdf')).toBeVisible();
});
it('renders "Kein Datum" when documentDate is missing', async () => {
render(PersonDocumentList, {
props: {
documents: [makeDoc({ documentDate: null })],
heading: 'X',
emptyMessage: 'X'
}
});
await expect.element(page.getByText('Kein Datum')).toBeVisible();
});
it('omits the location separator when location is null', async () => {
render(PersonDocumentList, {
props: {
documents: [makeDoc({ location: null })],
heading: 'X',
emptyMessage: 'X'
}
});
const meta = document.querySelector('.font-sans.text-\\[11px\\]');
expect(meta?.textContent ?? '').not.toMatch(/·/);
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonsNewPage from './+page.svelte';
afterEach(cleanup);
describe('persons/new page', () => {
it('renders the heading and details section by default', async () => {
render(PersonsNewPage, { props: { form: undefined } });
await expect.element(page.getByRole('heading', { name: /neue person/i })).toBeVisible();
await expect.element(page.getByRole('heading', { name: /angaben zur person/i })).toBeVisible();
});
it('renders the firstName field for the default PERSON type', async () => {
render(PersonsNewPage, { props: { form: undefined } });
await expect.element(page.getByLabelText(/vorname/i)).toBeVisible();
});
it('renders alias and life-year fields for the PERSON type', async () => {
render(PersonsNewPage, { props: { form: undefined } });
await expect.element(page.getByLabelText(/rufname/i)).toBeVisible();
});
it('hides firstName, alias, and life-year fields for the INSTITUTION type', async () => {
render(PersonsNewPage, { props: { form: { personType: 'INSTITUTION' } } });
await expect.element(page.getByLabelText(/vorname/i)).not.toBeInTheDocument();
await expect.element(page.getByLabelText(/rufname/i)).not.toBeInTheDocument();
});
it('changes the lastName label to "Name" for non-PERSON types', async () => {
render(PersonsNewPage, { props: { form: { personType: 'GROUP' } } });
await expect.element(page.getByLabelText(/^name \*$/i)).toBeVisible();
});
it('uses "Nachname" as the lastName label for PERSON type', async () => {
render(PersonsNewPage, { props: { form: undefined } });
await expect.element(page.getByLabelText(/nachname \*/i)).toBeVisible();
});
it('renders the form error banner when form.error is set', async () => {
render(PersonsNewPage, { props: { form: { error: 'Last name is required' } } });
await expect.element(page.getByText('Last name is required')).toBeVisible();
});
it('does not render the form error banner when form is undefined', async () => {
render(PersonsNewPage, { props: { form: undefined } });
await expect.element(page.getByText('Last name is required')).not.toBeInTheDocument();
});
it('hydrates lastName and firstName from prior form values', async () => {
render(PersonsNewPage, {
props: { form: { lastName: 'Müller', firstName: 'Anna', alias: 'Anni' } }
});
const lastName = (await page.getByLabelText(/nachname/i).element()) as HTMLInputElement;
const firstName = (await page.getByLabelText(/vorname/i).element()) as HTMLInputElement;
const alias = (await page.getByLabelText(/rufname/i).element()) as HTMLInputElement;
expect(lastName.value).toBe('Müller');
expect(firstName.value).toBe('Anna');
expect(alias.value).toBe('Anni');
});
it('renders cancel link pointing to /persons and a submit button', async () => {
render(PersonsNewPage, { props: { form: undefined } });
await expect
.element(page.getByRole('link', { name: /abbrechen/i }))
.toHaveAttribute('href', '/persons');
await expect.element(page.getByRole('button', { name: /erstellen/i })).toBeVisible();
});
it('falls back to PERSON when an unknown personType is supplied', async () => {
render(PersonsNewPage, { props: { form: { personType: 'NOT_A_REAL_TYPE' } } });
await expect.element(page.getByLabelText(/vorname/i)).toBeVisible();
});
});