diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts new file mode 100644 index 00000000..46e2da86 --- /dev/null +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import MentionDropdown from './MentionDropdown.svelte'; + +afterEach(cleanup); + +const makePerson = (id: string, name: string, overrides: Record = {}) => ({ + id, + firstName: name.split(' ')[0] ?? null, + lastName: name.split(' ').slice(1).join(' ') || name, + displayName: name, + birthYear: null as number | null, + deathYear: null as number | null, + ...overrides +}); + +const baseModel = (overrides: Record = {}) => ({ + items: [] as ReturnType[], + command: vi.fn(), + clientRect: () => new DOMRect(100, 100, 0, 24), + ...overrides +}); + +describe('MentionDropdown', () => { + it('renders the listbox with the mention label', async () => { + render(MentionDropdown, { props: { model: baseModel() } }); + + await expect.element(page.getByRole('listbox', { name: /person verlinken/i })).toBeVisible(); + }); + + it('renders the empty placeholder when items is empty', async () => { + render(MentionDropdown, { props: { model: baseModel() } }); + + await expect.element(page.getByText('Keine Personen gefunden')).toBeVisible(); + }); + + it('shows the create-new escape hatch link in the empty state', async () => { + render(MentionDropdown, { props: { model: baseModel() } }); + + const link = (await page + .getByRole('link', { name: /neue person anlegen/i }) + .element()) as HTMLAnchorElement; + expect(link.href).toContain('/persons/new'); + expect(link.target).toBe('_blank'); + expect(link.rel).toContain('noopener'); + }); + + it('renders one option per item when populated', async () => { + render(MentionDropdown, { + props: { + model: baseModel({ + items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')] + }) + } + }); + + await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); + await expect.element(page.getByText('Bert Meier')).toBeVisible(); + }); + + it('marks the first item as aria-selected by default', async () => { + render(MentionDropdown, { + props: { model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] }) } + }); + + const option = document.querySelector('[role="option"]'); + expect(option?.getAttribute('aria-selected')).toBe('true'); + }); + + it('renders the life-date range when birthYear or deathYear is present', async () => { + render(MentionDropdown, { + props: { + model: baseModel({ + items: [makePerson('p1', 'Anna', { birthYear: 1899, deathYear: 1972 })] + }) + } + }); + + await expect.element(page.getByText(/1899/)).toBeVisible(); + }); + + it('falls back to a default position when clientRect returns null', async () => { + render(MentionDropdown, { + props: { + model: baseModel({ clientRect: () => null }) + } + }); + + const dropdown = document.querySelector('[role="listbox"]') as HTMLElement; + expect(dropdown.style.left).toBe('0px'); + }); + + it('positions itself based on the clientRect callback', async () => { + render(MentionDropdown, { + props: { + model: baseModel({ + clientRect: () => new DOMRect(123, 200, 50, 24) + }) + } + }); + + const dropdown = document.querySelector('[role="listbox"]') as HTMLElement; + expect(dropdown.style.left).toBe('123px'); + }); +});