feat: PersonNameParser enhancements and Person model refactor (#209-#213) #215

Merged
marcel merged 25 commits from feat/issues-209-213-person-parser-enhancements into main 2026-04-08 18:48:00 +02:00
7 changed files with 219 additions and 45 deletions
Showing only changes of commit 1aabd9826c - Show all commits

View File

@@ -5,10 +5,10 @@ import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
afterEach(cleanup);
const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller' };
const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller', displayName: 'Karl Müller' };
const receivers = [
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt' },
{ id: 'r2', firstName: 'Hans', lastName: 'Weber' }
{ id: 'r1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' },
{ id: 'r2', firstName: 'Hans', lastName: 'Weber', displayName: 'Hans Weber' }
];
const tags = [
{ id: 't1', name: 'Familienbrief' },

View File

@@ -7,9 +7,21 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' },
{ id: '3', firstName: 'Karl', lastName: 'König' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
},
{ id: '3', firstName: 'Karl', lastName: 'König', displayName: 'Karl König', personType: 'PERSON' }
];
function mockFetch(persons = PERSONS) {
@@ -45,8 +57,20 @@ describe('PersonMultiSelect rendering', () => {
it('renders pre-selected persons as chips', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
]
});
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
@@ -57,8 +81,20 @@ describe('PersonMultiSelect rendering', () => {
it('renders hidden inputs for each selected person', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
]
});
await tick();
@@ -70,7 +106,15 @@ describe('PersonMultiSelect rendering', () => {
it('hides the placeholder when persons are selected', async () => {
render(PersonMultiSelect, {
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]
});
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
});
@@ -85,7 +129,7 @@ describe('PersonMultiSelect selecting persons', () => {
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await page.getByText('Max Mustermann').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
await page.screenshot({
@@ -100,11 +144,11 @@ describe('PersonMultiSelect selecting persons', () => {
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await page.getByText('Max Mustermann').click();
await input.fill('Mu');
await waitForDebounce();
await page.getByText('Musterfrau, Anna').click();
await page.getByText('Anna Musterfrau').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
@@ -116,22 +160,41 @@ describe('PersonMultiSelect selecting persons', () => {
it('filters already-selected persons from search results', async () => {
mockFetch();
render(PersonMultiSelect, {
selectedPersons: [{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]
selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]
});
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
// Chip still shows "Max Mustermann" but the dropdown item (role=button) must be filtered out
await expect
.element(page.getByRole('button', { name: 'Max Mustermann' }))
.not.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Anna Musterfrau' })).toBeInTheDocument();
});
it('selects a result with Enter key', async () => {
mockFetch([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
mockFetch([
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]);
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Ma');
await waitForDebounce();
await page.getByText('Mustermann, Max').click();
await page.getByText('Max Mustermann').click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
});
});
@@ -142,8 +205,20 @@ describe('PersonMultiSelect removing persons', () => {
it('removes a chip when its × button is clicked', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
]
});
// Buttons have aria-label="Entfernen"
@@ -156,8 +231,20 @@ describe('PersonMultiSelect removing persons', () => {
it('removes the corresponding hidden input when a chip is removed', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
]
});
await page.getByRole('button', { name: 'Entfernen' }).first().click();
@@ -177,9 +264,9 @@ describe('PersonMultiSelect click outside', () => {
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
document.body.click();
await tick();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});

View File

@@ -7,8 +7,20 @@ const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [
{ id: '1', firstName: 'Max', lastName: 'Mustermann' },
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON'
}
];
function mockFetchWithPersons(persons = PERSONS) {
@@ -76,8 +88,8 @@ describe('PersonTypeahead search', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
});
@@ -105,7 +117,7 @@ describe('PersonTypeahead search', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});
@@ -122,7 +134,7 @@ describe('PersonTypeahead selection', () => {
await tick();
await expect.element(input).toHaveValue('Max Mustermann');
await expect
.element(page.getByRole('button', { name: 'Mustermann, Max' }))
.element(page.getByRole('button', { name: 'Max Mustermann' }))
.not.toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
});
@@ -152,7 +164,15 @@ describe('PersonTypeahead selection', () => {
});
it('selects a result with Enter key', async () => {
mockFetchWithPersons([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
mockFetchWithPersons([
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON'
}
]);
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Ma');
@@ -218,7 +238,7 @@ describe('PersonTypeahead correspondent mode', () => {
(document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus();
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
});
it('uses correspondents endpoint with q param when typing', async () => {
@@ -259,9 +279,9 @@ describe('PersonTypeahead click outside', () => {
const input = page.getByPlaceholder('Namen tippen...');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
document.body.click();
await tick();
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});

View File

@@ -54,7 +54,15 @@ describe('korrespondenz load — senderId set, no receiverId', () => {
const docs = [{ id: 'd1', title: 'Testbrief' }];
const GET = mockApi([
{ ok: true, data: docs },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
{
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]);
const result = await load({
@@ -76,8 +84,24 @@ describe('korrespondenz load — senderId and receiverId set', () => {
it('calls conversation, sender person, and receiver person endpoints', async () => {
const GET = mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } },
{ ok: true, data: { firstName: 'Anna', lastName: 'Schmidt' } }
{
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
},
{
ok: true,
data: {
firstName: 'Anna',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON'
}
}
]);
const result = await load({
@@ -98,7 +122,15 @@ describe('korrespondenz load — canWrite', () => {
it('derives canWrite true from WRITE_ALL permission', async () => {
mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
{
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]);
const result = await load({
@@ -113,7 +145,15 @@ describe('korrespondenz load — canWrite', () => {
it('derives canWrite false when user lacks WRITE_ALL', async () => {
mockApi([
{ ok: true, data: [] },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
{
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]);
const result = await load({
@@ -132,7 +172,15 @@ describe('korrespondenz load — backend error', () => {
it('throws when the conversation endpoint returns non-ok', async () => {
mockApi([
{ ok: false, status: 500 },
{ ok: true, data: { firstName: 'Hans', lastName: 'Müller' } }
{
ok: true,
data: {
firstName: 'Hans',
lastName: 'Müller',
displayName: 'Hans Müller',
personType: 'PERSON'
}
}
]);
await expect(

View File

@@ -58,7 +58,9 @@ describe('New document page receiver prefill', () => {
it('shows a receiver chip when initialReceivers has a person', async () => {
const data = {
...baseData,
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
initialReceivers: [
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
]
};
render(Page, { data, form: null });
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
@@ -67,7 +69,9 @@ describe('New document page receiver prefill', () => {
it('renders a hidden receiverIds input for the prefilled receiver', async () => {
const data = {
...baseData,
initialReceivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt' }]
initialReceivers: [
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
]
};
render(Page, { data, form: null });
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');

View File

@@ -39,8 +39,22 @@ const makeDoc = (overrides = {}) => ({
status: 'UPLOADED' as const,
documentDate: '2024-03-15',
location: 'Berlin',
sender: { id: 'p1', firstName: 'Max', lastName: 'Mustermann' },
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Musterfrau' }],
sender: {
id: 'p1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON' as const
},
receivers: [
{
id: 'p2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON' as const
}
],
tags: [{ id: 't1', name: 'Familie' }],
filePath: '/files/testbrief.pdf',
createdAt: '2024-03-15T10:00:00Z',

View File

@@ -11,6 +11,7 @@ const makePerson = (overrides = {}) => ({
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
documentCount: 0,
...overrides
});