import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte'; import type { components } from '$lib/generated/api'; type Person = components['schemas']['Person']; type PersonMention = components['schemas']['PersonMention']; // Editor's internal search debounce is 200ms — drive it via fake timers // so tests are deterministic and fast (Tester #5506 §1). const DEBOUNCE_MS = 200; async function flushDebounce() { await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); // Let the awaited fetch resolve and the resulting state assignments flush. await vi.runAllTimersAsync(); } async function tick() { await vi.advanceTimersByTimeAsync(0); } const AUGUSTE: Person = { id: 'p-aug', firstName: 'Auguste', lastName: 'Raddatz', displayName: 'Auguste Raddatz', birthYear: 1882, deathYear: 1944 } as unknown as Person; const ANNA: Person = { id: 'p-anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt', birthYear: 1860 } as unknown as Person; function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }); vi.stubGlobal('fetch', fetchMock); return fetchMock; } function mockFetchEmpty() { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); vi.stubGlobal('fetch', fetchMock); return fetchMock; } function getTextarea(): HTMLTextAreaElement { return document.querySelector('textarea')!; } function clickOption(personId: string) { const opt = document.querySelector( `[role="option"][data-test-person-id="${personId}"]` ) as HTMLElement; opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); } type Snapshot = { value: string; mentionedPersons: PersonMention[] }; function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) { let snapshot: Snapshot = { value: initial.value ?? '', mentionedPersons: initial.mentionedPersons ?? [] }; render(PersonMentionEditorHost, { initialValue: initial.value ?? '', initialMentions: initial.mentionedPersons ?? [], onChange: (snap: Snapshot) => { snapshot = snap; } }); return { get snapshot() { return snapshot; } }; } beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); }); afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.useRealTimers(); }); // ─── Rendering ──────────────────────────────────────────────────────────────── describe('PersonMentionEditor — rendering', () => { it('renders the textarea with placeholder', async () => { render(PersonMentionEditorHost, { initialValue: '', initialMentions: [], placeholder: 'Transkription…', onChange: () => {} }); await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument(); }); it('reflects bound initial value', async () => { render(PersonMentionEditorHost, { initialValue: 'Hallo Welt', initialMentions: [], onChange: () => {} }); await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt'); }); }); // ─── Typeahead opens on @ ───────────────────────────────────────────────────── describe('PersonMentionEditor — typeahead', () => { it('opens the popup when typing @ + query and shows results', async () => { mockFetchWithPersons(); renderHost(); const ta = getTextarea(); ta.focus(); ta.value = '@Aug'; ta.selectionStart = 4; ta.selectionEnd = 4; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); }); it('hits /api/persons?q= with the typed query', async () => { const fetchMock = mockFetchWithPersons(); renderHost(); const ta = getTextarea(); ta.focus(); ta.value = '@Aug'; ta.selectionStart = 4; ta.selectionEnd = 4; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); }); it('shows life dates next to the name in the dropdown', async () => { mockFetchWithPersons(); renderHost(); const ta = getTextarea(); ta.focus(); ta.value = '@Aug'; ta.selectionStart = 4; ta.selectionEnd = 4; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); }); it('shows empty state when no persons match', async () => { mockFetchEmpty(); renderHost(); const ta = getTextarea(); ta.focus(); ta.value = '@xyz'; ta.selectionStart = 4; ta.selectionEnd = 4; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); }); it('keeps the popup open when the query has a trailing space (multi-word names)', async () => { mockFetchWithPersons(); renderHost(); const ta = getTextarea(); ta.focus(); ta.value = '@Auguste '; ta.selectionStart = 9; ta.selectionEnd = 9; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); }); }); // ─── Selection writes text + sidecar ───────────────────────────────────────── describe('PersonMentionEditor — selecting a person', () => { it('inserts @DisplayName followed by a trailing space into the textarea', async () => { mockFetchWithPersons(); const host = renderHost(); const ta = getTextarea(); ta.focus(); ta.value = '@Aug'; ta.selectionStart = 4; ta.selectionEnd = 4; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); clickOption('p-aug'); await tick(); expect(host.snapshot.value).toBe('@Auguste Raddatz '); }); it('pushes {personId, displayName} into the bound mentionedPersons array', async () => { mockFetchWithPersons(); const host = renderHost(); const ta = getTextarea(); ta.focus(); ta.value = '@Aug'; ta.selectionStart = 4; ta.selectionEnd = 4; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); clickOption('p-aug'); await tick(); expect(host.snapshot.mentionedPersons).toEqual([ { personId: 'p-aug', displayName: 'Auguste Raddatz' } ]); }); it('does not duplicate the sidecar entry when the same person is selected twice', async () => { mockFetchWithPersons(); const host = renderHost({ value: '@Auguste Raddatz ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] }); const ta = getTextarea(); ta.focus(); ta.value = '@Auguste Raddatz @Aug'; ta.selectionStart = ta.value.length; ta.selectionEnd = ta.value.length; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); clickOption('p-aug'); await tick(); expect(host.snapshot.mentionedPersons).toHaveLength(1); }); }); // ─── Keyboard navigation (B11b) ────────────────────────────────────────────── describe('PersonMentionEditor — keyboard navigation (B11b)', () => { it('ArrowDown / ArrowUp cycle the highlighted result', async () => { mockFetchWithPersons(); renderHost(); const ta = getTextarea(); ta.focus(); ta.value = '@A'; ta.selectionStart = 2; ta.selectionEnd = 2; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); const optAuguste = document.querySelector( '[role="option"][data-test-person-id="p-aug"]' ) as HTMLElement; const optAnna = document.querySelector( '[role="option"][data-test-person-id="p-anna"]' ) as HTMLElement; expect(optAuguste.getAttribute('aria-selected')).toBe('true'); expect(optAnna.getAttribute('aria-selected')).toBe('false'); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await tick(); expect(optAuguste.getAttribute('aria-selected')).toBe('false'); expect(optAnna.getAttribute('aria-selected')).toBe('true'); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await tick(); expect(optAuguste.getAttribute('aria-selected')).toBe('true'); expect(optAnna.getAttribute('aria-selected')).toBe('false'); }); it('Enter selects the currently highlighted result', async () => { mockFetchWithPersons(); const host = renderHost(); const ta = getTextarea(); ta.focus(); ta.value = '@A'; ta.selectionStart = 2; ta.selectionEnd = 2; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await tick(); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await tick(); expect(host.snapshot.mentionedPersons).toEqual([ { personId: 'p-anna', displayName: 'Anna Schmidt' } ]); }); it('Escape closes the popup without inserting anything', async () => { mockFetchWithPersons(); const host = renderHost(); const ta = getTextarea(); ta.focus(); ta.value = '@Aug'; ta.selectionStart = 4; ta.selectionEnd = 4; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await tick(); await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); expect(host.snapshot.value).toBe('@Aug'); expect(host.snapshot.mentionedPersons).toEqual([]); }); }); // ─── Touch target (WCAG 2.2 AA) ────────────────────────────────────────────── describe('PersonMentionEditor — touch target', () => { it('each result row has min-h-[44px] (WCAG 2.2 AA)', async () => { mockFetchWithPersons(); renderHost(); const ta = getTextarea(); ta.focus(); ta.value = '@Aug'; ta.selectionStart = 4; ta.selectionEnd = 4; ta.dispatchEvent(new Event('input', { bubbles: true })); await flushDebounce(); const option = document.querySelector('[role="option"]') as HTMLElement; expect(option).not.toBeNull(); expect(option.className).toContain('min-h-[44px]'); }); });