diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index df686334..721831df 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -1,263 +1,242 @@ -
- - - {#if popupOpen} -
- {#if loading} -

{m.comp_typeahead_loading()}

- {:else if results.length === 0} -
-

{m.person_mention_popup_empty()}

- - {m.person_mention_create_new()} → - -
- {:else} - {#each results as person, i (person.id)} -
{ - e.preventDefault(); - selectPerson(person); - }} - > - {person.displayName} - {#if formatLifeDateRange(person.birthYear, person.deathYear)} - - {formatLifeDateRange(person.birthYear, person.deathYear)} - - {/if} -
- {/each} - {/if} -
- {/if} -
+
diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts index ea8df7fe..39a1dc15 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts @@ -1,26 +1,19 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +/** + * PersonMentionEditor — Tiptap-based component tests. + * + * All old tests used document.querySelector('textarea') which is dead after + * the Tiptap migration. These tests drive the contenteditable via + * userEvent.type() and inspect the serialized output from the test host. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; +import { page, userEvent } 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', @@ -39,34 +32,17 @@ const ANNA: Person = { } 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; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }) + ); } function mockFetchEmpty() { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; -} - -function mockFetchRejects() { - const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); - 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 })); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) + ); } type Snapshot = { value: string; mentionedPersons: PersonMention[] }; @@ -90,275 +66,216 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[ }; } -beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); -}); - afterEach(() => { cleanup(); vi.unstubAllGlobals(); - vi.useRealTimers(); }); // ─── Rendering ──────────────────────────────────────────────────────────────── describe('PersonMentionEditor — rendering', () => { - it('renders the textarea with placeholder', async () => { + it('renders the editor as a textbox (ARIA role from editorProps)', async () => { render(PersonMentionEditorHost, { initialValue: '', initialMentions: [], - placeholder: 'Transkription…', onChange: () => {} }); - await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).toBeInTheDocument(); }); - it('reflects bound initial value', async () => { + it('reflects bound initial value as visible text', async () => { render(PersonMentionEditorHost, { initialValue: 'Hallo Welt', initialMentions: [], onChange: () => {} }); - await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt'); + await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument(); }); }); // ─── Typeahead opens on @ ───────────────────────────────────────────────────── describe('PersonMentionEditor — typeahead', () => { - it('opens the popup when typing @ + query and shows results', async () => { + it('opens the dropdown 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 userEvent.type(page.getByRole('textbox'), '@Aug'); - await flushDebounce(); - - await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + await vi.waitFor(async () => { + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); }); it('hits /api/persons?q= with the typed query', async () => { - const fetchMock = mockFetchWithPersons(); + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); + vi.stubGlobal('fetch', fetchMock); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); + await userEvent.type(page.getByRole('textbox'), '@Aug'); - await flushDebounce(); - - expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); + await vi.waitFor(() => { + 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 userEvent.type(page.getByRole('textbox'), '@Aug'); - await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); + await vi.waitFor(async () => { + 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 userEvent.type(page.getByRole('textbox'), '@xyz'); - await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); - }); - - it('falls back to the empty state when the typeahead fetch rejects (network error)', async () => { - mockFetchRejects(); - 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('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(); + await vi.waitFor(async () => { + await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); + }); }); }); -// ─── Selection writes text + sidecar ───────────────────────────────────────── +// ─── AC-1: typed text becomes displayName, not DB name ─────────────────────── -describe('PersonMentionEditor — selecting a person', () => { - it('inserts @DisplayName followed by a trailing space into the textarea', async () => { +describe('PersonMentionEditor — AC-1: typed text as displayName', () => { + it('stores the typed query as displayName, not the person DB name', 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(); + // User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - clickOption('p-aug'); - await tick(); + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); - expect(host.snapshot.value).toBe('@Auguste Raddatz '); + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toHaveLength(1); + expect(host.snapshot.mentionedPersons[0]).toEqual({ + personId: 'p-aug', + displayName: 'Aug' // typed text, not "Auguste Raddatz" + }); + }); }); - it('pushes {personId, displayName} into the bound mentionedPersons array', async () => { + it('regression: text value contains the typed query, not the full DB name', 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(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - clickOption('p-aug'); - await tick(); + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); - expect(host.snapshot.mentionedPersons).toEqual([ - { personId: 'p-aug', displayName: 'Auguste Raddatz' } - ]); + await vi.waitFor(() => { + // Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz " + expect(host.snapshot.value).toContain('@Aug'); + expect(host.snapshot.value).not.toContain('@Auguste Raddatz'); + }); + }); + + it('pushes {personId, displayName} into mentionedPersons sidecar', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); + + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); + + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]); + }); }); 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' }] + value: '@Aug ', + mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] }); - 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(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - clickOption('p-aug'); - await tick(); + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); - expect(host.snapshot.mentionedPersons).toHaveLength(1); + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toHaveLength(1); + }); }); }); -// ─── Keyboard navigation (B11b) ────────────────────────────────────────────── +// ─── Keyboard navigation ────────────────────────────────────────────────────── -describe('PersonMentionEditor — keyboard navigation (B11b)', () => { - it('ArrowDown / ArrowUp cycle the highlighted result', async () => { +describe('PersonMentionEditor — keyboard navigation', () => { + it('Enter selects the highlighted result', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + await userEvent.type(page.getByRole('textbox'), '@A'); + + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); + + await userEvent.keyboard('{Enter}'); + + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toHaveLength(1); + }); + }); + + it('ArrowDown moves the highlight to the next 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(); + await userEvent.type(page.getByRole('textbox'), '@A'); - 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; + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - expect(optAuguste.getAttribute('aria-selected')).toBe('true'); - expect(optAnna.getAttribute('aria-selected')).toBe('false'); + await userEvent.keyboard('{ArrowDown}'); - 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'); + await vi.waitFor(async () => { + const annaOption = page.getByRole('option', { name: /Anna Schmidt/ }); + await expect.element(annaOption).toHaveAttribute('aria-selected', 'true'); + }); }); - it('Enter selects the currently highlighted result', async () => { + it('Escape closes the dropdown without inserting', 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(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); - await tick(); - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - await tick(); + await vi.waitFor(async () => { + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); - expect(host.snapshot.mentionedPersons).toEqual([ - { personId: 'p-anna', displayName: 'Anna Schmidt' } - ]); - }); + await userEvent.keyboard('{Escape}'); - it('Escape closes the popup without inserting anything', async () => { - mockFetchWithPersons(); - const host = renderHost(); + await vi.waitFor(async () => { + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + }); - 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([]); }); }); @@ -370,13 +287,11 @@ describe('PersonMentionEditor — touch target', () => { 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 userEvent.type(page.getByRole('textbox'), '@Aug'); + + await vi.waitFor(async () => { + await expect.element(page.getByRole('option').first()).toBeVisible(); + }); const option = document.querySelector('[role="option"]') as HTMLElement; expect(option).not.toBeNull();