diff --git a/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts b/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts new file mode 100644 index 00000000..227edbea --- /dev/null +++ b/frontend/src/routes/briefwechsel/CorrespondentSuggestionsDropdown.svelte.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import CorrespondentSuggestionsDropdown from './CorrespondentSuggestionsDropdown.svelte'; + +afterEach(cleanup); + +const corrA = { id: 'a', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }; +const corrB = { id: 'b', firstName: null, lastName: 'Müller', displayName: 'Müller' }; + +describe('CorrespondentSuggestionsDropdown', () => { + it('renders the heading and the "all correspondents" row even when the list is empty', async () => { + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [], + loading: false, + senderName: 'Anna', + onselect: () => {}, + onclose: () => {} + } + }); + + await expect.element(page.getByText('Häufigste Korrespondenten')).toBeVisible(); + await expect.element(page.getByText('Alle Korrespondenten von Anna')).toBeVisible(); + }); + + it('renders one row per correspondent when not loading', async () => { + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA, corrB], + loading: false, + senderName: 'Anna', + onselect: () => {}, + onclose: () => {} + } + }); + + await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); + await expect.element(page.getByText('Müller')).toBeVisible(); + }); + + it('hides correspondent rows while loading is true', async () => { + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA], + loading: true, + senderName: 'Anna', + onselect: () => {}, + onclose: () => {} + } + }); + + await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument(); + await expect.element(page.getByText('Häufigste Korrespondenten')).toBeVisible(); + }); + + it('builds initials from firstName + lastName when available', async () => { + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA], + loading: false, + senderName: 'Anna', + onselect: () => {}, + onclose: () => {} + } + }); + + await expect.element(page.getByText('AS')).toBeVisible(); + }); + + it('falls back to the first two letters of lastName when firstName is missing', async () => { + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrB], + loading: false, + senderName: 'Anna', + onselect: () => {}, + onclose: () => {} + } + }); + + await expect.element(page.getByText('MÜ')).toBeVisible(); + }); + + it('calls onselect with the correspondent id when a row is clicked', async () => { + const onselect = vi.fn(); + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA], + loading: false, + senderName: 'Anna', + onselect, + onclose: () => {} + } + }); + + await page.getByText('Anna Schmidt').click(); + + expect(onselect).toHaveBeenCalledWith('a'); + }); + + it('calls onselect with an empty string when the "all correspondents" row is clicked', async () => { + const onselect = vi.fn(); + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [], + loading: false, + senderName: 'Anna', + onselect, + onclose: () => {} + } + }); + + await page.getByText('Alle Korrespondenten von Anna').click(); + + expect(onselect).toHaveBeenCalledWith(''); + }); + + it('calls onselect via Enter key on a focused row', async () => { + const onselect = vi.fn(); + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA], + loading: false, + senderName: 'Anna', + onselect, + onclose: () => {} + } + }); + + const row = (await page.getByText('Anna Schmidt').element()) as HTMLElement; + row.focus(); + row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + expect(onselect).toHaveBeenCalledWith('a'); + }); + + it('calls onclose when the Escape key is pressed', async () => { + const onclose = vi.fn(); + render(CorrespondentSuggestionsDropdown, { + props: { + correspondents: [corrA], + loading: false, + senderName: 'Anna', + onselect: () => {}, + onclose + } + }); + + const list = (await page.getByRole('listbox').element()) as HTMLElement; + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(onclose).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/routes/persons/[id]/PersonCard.svelte.test.ts b/frontend/src/routes/persons/[id]/PersonCard.svelte.test.ts new file mode 100644 index 00000000..b2c7c4fc --- /dev/null +++ b/frontend/src/routes/persons/[id]/PersonCard.svelte.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonCard from './PersonCard.svelte'; + +afterEach(cleanup); + +const basePerson = { + id: 'p-1', + firstName: 'Anna', + lastName: 'Schmidt', + displayName: 'Anna Schmidt', + personType: 'PERSON' as const +}; + +describe('PersonCard', () => { + it('renders the displayName as the primary heading', async () => { + render(PersonCard, { props: { person: basePerson, canWrite: false } }); + + await expect.element(page.getByRole('heading', { name: 'Anna Schmidt' })).toBeVisible(); + }); + + it('renders the title above the name when personType is PERSON and title is set', async () => { + render(PersonCard, { + props: { person: { ...basePerson, title: 'Frau Dr.' }, canWrite: false } + }); + + await expect.element(page.getByText('Frau Dr.')).toBeVisible(); + }); + + it('omits the title for non-PERSON types even if title is set', async () => { + render(PersonCard, { + props: { + person: { ...basePerson, personType: 'INSTITUTION', title: 'Frau Dr.' }, + canWrite: false + } + }); + + await expect.element(page.getByText('Frau Dr.')).not.toBeInTheDocument(); + }); + + it('renders the firstName/lastName initials inside the avatar for PERSON type', async () => { + render(PersonCard, { props: { person: basePerson, canWrite: false } }); + + await expect.element(page.getByText('AS')).toBeVisible(); + }); + + it('falls back to lastName-only initials when firstName is missing', async () => { + render(PersonCard, { + props: { + person: { ...basePerson, firstName: null, displayName: 'Schmidt' }, + canWrite: false + } + }); + + await expect.element(page.getByText('SS')).toBeVisible(); + }); + + it('renders the PersonTypeBadge for non-PERSON types', async () => { + render(PersonCard, { + props: { + person: { ...basePerson, personType: 'INSTITUTION', displayName: 'Acme Inc.' }, + canWrite: false + } + }); + + await expect.element(page.getByText('Institution')).toBeVisible(); + }); + + it('omits the PersonTypeBadge for PERSON type', async () => { + render(PersonCard, { props: { person: basePerson, canWrite: false } }); + + await expect.element(page.getByText('Institution')).not.toBeInTheDocument(); + await expect.element(page.getByText('Gruppe')).not.toBeInTheDocument(); + }); + + it('renders the alias in italic typography when alias is provided', async () => { + render(PersonCard, { + props: { person: { ...basePerson, alias: 'Annerl' }, canWrite: false } + }); + + await expect.element(page.getByText(/Annerl/)).toBeVisible(); + }); + + it('renders the life-date range when birthYear or deathYear are present', async () => { + render(PersonCard, { + props: { + person: { ...basePerson, birthYear: 1899, deathYear: 1972 }, + canWrite: false + } + }); + + await expect.element(page.getByText(/1899/)).toBeVisible(); + }); + + it('renders the notes section when notes are provided', async () => { + render(PersonCard, { + props: { + person: { ...basePerson, notes: 'Wohnte in Berlin.' }, + canWrite: false + } + }); + + await expect.element(page.getByText('Wohnte in Berlin.')).toBeVisible(); + }); + + it('renders the edit link when canWrite is true', async () => { + render(PersonCard, { props: { person: basePerson, canWrite: true } }); + + await expect + .element(page.getByRole('link', { name: /bearbeiten/i })) + .toHaveAttribute('href', '/persons/p-1/edit'); + }); + + it('does not render the edit link when canWrite is false', async () => { + render(PersonCard, { props: { person: basePerson, canWrite: false } }); + + await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); + }); +});