import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; import { flushSync, mount, unmount } from 'svelte'; import MentionDropdown from './MentionDropdown.svelte'; import MentionDropdownFixture from './MentionDropdown.test-fixture.svelte'; import { m } from '$lib/paraglide/messages.js'; import type { components } from '$lib/generated/api'; type Person = components['schemas']['Person']; afterEach(cleanup); const makePerson = (id: string, name: string, overrides: Partial = {}): Person => { const parts = name.split(' '); return { id, firstName: parts[0], lastName: parts.slice(1).join(' ') || name, displayName: name, personType: 'PERSON', familyMember: false, ...overrides }; }; type DropdownState = { items: Person[]; command: (item: Person) => void; clientRect: (() => DOMRect | null) | null; }; const baseModel = (overrides: Partial = {}): DropdownState => ({ items: [], 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('shows the "enter a name" prompt when the search field is empty', async () => { render(MentionDropdown, { props: { model: baseModel() } }); await expect.element(page.getByText(m.person_mention_search_prompt())).toBeVisible(); await expect.element(page.getByText(m.person_mention_popup_empty())).not.toBeInTheDocument(); }); it('shows "no persons found" when the search has a query but the list is empty', async () => { render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } }); await expect.element(page.getByText(m.person_mention_popup_empty())).toBeVisible(); await expect.element(page.getByText(m.person_mention_search_prompt())).not.toBeInTheDocument(); }); it('shows the create-new escape hatch link in the empty state', async () => { render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'unknown' } }); 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'); expect(link.rel).toContain('noreferrer'); }); 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'); }); }); // ─── Search input — Issue #380 ──────────────────────────────────────────────── describe('MentionDropdown — search input', () => { it('renders a search input pre-filled with the editorQuery prop', async () => { render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } }); await expect.element(page.getByRole('searchbox')).toHaveValue('WdG'); }); it('exposes a data-test-search-input attribute for E2E selectors', async () => { render(MentionDropdown, { props: { model: baseModel() } }); const input = document.querySelector('[data-test-search-input]'); expect(input).not.toBeNull(); expect((input as HTMLInputElement).type).toBe('search'); }); it('search input wrapper meets the 44px touch target (WCAG 2.2 AA)', async () => { render(MentionDropdown, { props: { model: baseModel() } }); const input = document.querySelector('[data-test-search-input]') as HTMLElement; expect(input).not.toBeNull(); expect(input.className).toContain('min-h-[44px]'); }); it('announces empty-state copy via aria-live="polite" (Leonie FINDING-MENTION-002 on PR #629)', async () => { render(MentionDropdown, { props: { model: baseModel() } }); const listbox = document.querySelector('[role="listbox"]'); expect(listbox).not.toBeNull(); const live = listbox!.querySelector('p[aria-live="polite"]'); expect(live).not.toBeNull(); expect(live!.textContent ?? '').toContain(m.person_mention_search_prompt()); }); it('renders the magnifier icon at h-5 w-5 with text-ink-2 (Leonie BLOCKER on PR #629)', async () => { render(MentionDropdown, { props: { model: baseModel() } }); const icon = document.querySelector('[data-test-search-input]') ?.previousElementSibling as SVGElement | null; expect(icon).not.toBeNull(); expect(icon!.tagName.toLowerCase()).toBe('svg'); expect(icon!.getAttribute('class') ?? '').toContain('h-5'); expect(icon!.getAttribute('class') ?? '').toContain('w-5'); expect(icon!.getAttribute('class') ?? '').toContain('text-ink-2'); }); it('caps the search input at maxlength=100 (CWE-400 amplification — Nora on PR #629)', async () => { render(MentionDropdown, { props: { model: baseModel() } }); const input = document.querySelector('[data-test-search-input]') as HTMLInputElement; expect(input).not.toBeNull(); expect(input.maxLength).toBe(100); }); it('clips a long editorQuery mirror to 100 chars (CWE-400 layered — Nora #1 on PR #629)', async () => { const longQuery = 'A'.repeat(200); render(MentionDropdown, { props: { model: baseModel(), editorQuery: longQuery } }); const input = document.querySelector('[data-test-search-input]') as HTMLInputElement; expect(input).not.toBeNull(); expect(input.value.length).toBe(100); expect(input.value).toBe('A'.repeat(100)); }); it('caps the listbox width to the viewport (320 px reflow guard — Leonie FINDING-MENTION-005)', async () => { render(MentionDropdown, { props: { model: baseModel() } }); const listbox = document.querySelector('[role="listbox"]') as HTMLElement; expect(listbox).not.toBeNull(); expect(listbox.className).toContain('max-w-[calc(100vw-1rem)]'); }); it('invokes onSearch with the current value whenever the user types', async () => { const onSearch = vi.fn(); render(MentionDropdown, { props: { model: baseModel(), onSearch } }); await userEvent.type(page.getByRole('searchbox'), 'Walter'); await vi.waitFor(() => { expect(onSearch).toHaveBeenCalled(); expect(onSearch).toHaveBeenLastCalledWith('Walter'); }); }); it('keeps the user-edited search value when editorQuery changes after the takeover (Felix on PR #629)', async () => { let setEditorQuery!: (q: string) => void; render(MentionDropdownFixture, { model: baseModel(), initialEditorQuery: 'WdG', onReady: (s: (q: string) => void) => { setEditorQuery = s; } }); await expect.element(page.getByRole('searchbox')).toHaveValue('WdG'); await page.getByRole('searchbox').fill('Walter'); await expect.element(page.getByRole('searchbox')).toHaveValue('Walter'); setEditorQuery('WdGruyter'); await new Promise((r) => setTimeout(r, 50)); await expect.element(page.getByRole('searchbox')).toHaveValue('Walter'); }); }); // ─── ArrowDown via exported onKeyDown (Sara #3 on PR #629) ────────────────── // // In production, Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level // and forwards them to the dropdown via its exported onKeyDown(event) function // — the dropdown itself has no DOM keydown listener. This test exercises the // same export so a regression in highlightedIndex/selection logic is caught // at the unit level. The full E2E focus-chain test is deferred to a separate // issue (Playwright). describe('MentionDropdown — onKeyDown forwarding', () => { it('ArrowDown advances aria-selected to the next option in the listbox', async () => { const container = document.createElement('div'); document.body.appendChild(container); const instance = mount(MentionDropdown, { target: container, props: { model: baseModel({ items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')] }) } }); try { const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean }; // First option starts highlighted. const first = container.querySelector('[data-test-person-id="p1"]') as HTMLElement; const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement; expect(first.getAttribute('aria-selected')).toBe('true'); expect(second.getAttribute('aria-selected')).toBe('false'); let consumed = false; flushSync(() => { consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })); }); expect(consumed).toBe(true); expect(first.getAttribute('aria-selected')).toBe('false'); expect(second.getAttribute('aria-selected')).toBe('true'); } finally { unmount(instance); container.remove(); } }); it('ArrowUp wraps from the first option to the last', async () => { const container = document.createElement('div'); document.body.appendChild(container); const instance = mount(MentionDropdown, { target: container, props: { model: baseModel({ items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')] }) } }); try { const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean }; let consumed = false; flushSync(() => { consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' })); }); expect(consumed).toBe(true); const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement; expect(second.getAttribute('aria-selected')).toBe('true'); } finally { unmount(instance); container.remove(); } }); it('Enter invokes model.command with the currently highlighted item', async () => { const command = vi.fn(); const container = document.createElement('div'); document.body.appendChild(container); const instance = mount(MentionDropdown, { target: container, props: { model: baseModel({ items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')], command }) } }); try { const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean }; const consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })); expect(consumed).toBe(true); expect(command).toHaveBeenCalledTimes(1); expect(command.mock.calls[0][0].id).toBe('p1'); } finally { unmount(instance); container.remove(); } }); it('Escape returns false so the suggestion plugin can handle it', async () => { const container = document.createElement('div'); document.body.appendChild(container); const instance = mount(MentionDropdown, { target: container, props: { model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] }) } }); try { const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean }; const consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Escape' })); expect(consumed).toBe(false); } finally { unmount(instance); container.remove(); } }); });