diff --git a/frontend/src/routes/persons/[id]/PersonDocumentList.svelte.test.ts b/frontend/src/routes/persons/[id]/PersonDocumentList.svelte.test.ts new file mode 100644 index 00000000..9b6c69a0 --- /dev/null +++ b/frontend/src/routes/persons/[id]/PersonDocumentList.svelte.test.ts @@ -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 = {}) => ({ + 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 min–max 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(/·/); + }); +}); diff --git a/frontend/src/routes/persons/new/page.svelte.test.ts b/frontend/src/routes/persons/new/page.svelte.test.ts new file mode 100644 index 00000000..b1c85674 --- /dev/null +++ b/frontend/src/routes/persons/new/page.svelte.test.ts @@ -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(); + }); +});