diff --git a/frontend/src/lib/person/PersonCard.svelte b/frontend/src/lib/person/PersonCard.svelte index feb609ad..aed13bd4 100644 --- a/frontend/src/lib/person/PersonCard.svelte +++ b/frontend/src/lib/person/PersonCard.svelte @@ -8,27 +8,26 @@ type Person = components['schemas']['PersonSummaryDTO']; let { person }: { person: Person } = $props(); -// A card is "unconfirmed" when the importer could not identify the person: the explicit -// provisional flag, the placeholder UNKNOWN type, or a literal "?" name from the spreadsheet. -const isUnconfirmed = $derived( - person.provisional === true || - person.personType === 'UNKNOWN' || - person.lastName === '?' || - (person.lastName ?? '').trim() === '' -); +// "Unconfirmed" is exactly the `provisional` flag — the authoritative signal the importer +// sets and the triage flow clears. The badge, the "Zu prüfen (N)" count and the +// /persons/review list all key off this same flag, so badge ⇔ count ⇔ triage can never drift. +const isUnconfirmed = $derived(person.provisional === true); -// A non-PERSON type (institution/group) gets a glyph; everything that is a real, confirmed -// person gets initials. Unconfirmed persons never get a "?" initial — they get the neutral -// placeholder glyph instead. Reading lastName[0] on a null/empty name would throw, so the -// initials branch is gated on a non-empty name. +// An empty / "?" last name is a separate, purely defensive concern: it must not crash the +// initials branch (reading lastName[0] on null throws) and must never render a "?" initial. +// It implies the placeholder glyph but — on its own — no "unbestätigt" badge. +const hasNoName = $derived(person.lastName == null || person.lastName.trim() === '' || person.lastName === '?'); + +// A non-PERSON type (institution/group) gets a typed glyph; a confirmed, named person gets +// initials. Provisional entries and nameless entries fall back to the neutral placeholder glyph. const showGlyph = $derived( - isUnconfirmed || (person.personType != null && person.personType !== 'PERSON') + isUnconfirmed || hasNoName || (person.personType != null && person.personType !== 'PERSON') ); const initials = $derived.by(() => { const first = person.firstName?.[0] ?? ''; const last = person.lastName?.[0] ?? ''; - return (first || last) + last; + return first ? first + last : last; }); const documentCount = $derived(person.documentCount ?? 0); @@ -43,7 +42,7 @@ const documentCount = $derived(person.documentCount ?? 0);
{#if showGlyph} @@ -98,7 +97,6 @@ const documentCount = $derived(person.documentCount ?? 0); { }); }); -describe('PersonCard — unconfirmed / malformed (regression: null-lastName crash)', () => { +describe('PersonCard — unconfirmed badge keys off provisional only (badge ⇔ count ⇔ triage parity)', () => { it('renders without throwing when lastName is null', async () => { - // Before the fix, `lastName[0]` threw at render for a null lastName. + // Before the fix, `lastName[0]` threw at render for a null lastName. Empty-name + // crash-safety is a SEPARATE concern from the badge: the placeholder glyph renders + // regardless, but the "unbestätigt" badge only fires when provisional is true. const person = makePerson({ lastName: null as unknown as string, displayName: '?', provisional: true }); render(PersonCard, { props: { person } }); - // No throw + the placeholder avatar (an ) is present, never a "?" initial. + // No throw + provisional → the badge is shown. await expect.element(page.getByText('unbestätigt')).toBeVisible(); }); @@ -50,17 +52,36 @@ describe('PersonCard — unconfirmed / malformed (regression: null-lastName cras await expect.element(page.getByText('unbestätigt')).toBeVisible(); }); - it('shows a placeholder (no "?" initial) for a "?" name', async () => { + it('does NOT show the badge for a "?" name when not provisional', async () => { + // Empty/"?" name alone is no longer treated as unconfirmed — only `provisional` is. + // This keeps the badge in lockstep with needsReviewCount and the /persons/review list. render(PersonCard, { - props: { person: makePerson({ firstName: undefined, lastName: '?', displayName: '?' }) } + props: { + person: makePerson({ firstName: undefined, lastName: '?', displayName: '?' }) + } }); - await expect.element(page.getByText('unbestätigt')).toBeVisible(); + await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument(); }); - it('treats an UNKNOWN type as unconfirmed', async () => { + it('does NOT show the badge for an UNKNOWN type when not provisional', async () => { render(PersonCard, { props: { person: makePerson({ personType: 'UNKNOWN', displayName: 'Unklar' }) } }); - await expect.element(page.getByText('unbestätigt')).toBeVisible(); + await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument(); + }); + + it('renders the placeholder glyph (never a "?" initial) for an empty name even without provisional', async () => { + // Crash-safety branch: a null/empty lastName must not throw and must not show "?". + render(PersonCard, { + props: { + person: makePerson({ + firstName: undefined, + lastName: null as unknown as string, + displayName: 'Unbekannt' + }) + } + }); + await expect.element(page.getByText('Unbekannt')).toBeVisible(); + await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument(); }); });