From d87ad362785b2379adb3335d22c1ff49bd34c6c2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 29 Apr 2026 15:53:21 +0200 Subject: [PATCH] feat(PersonMentionEditor): rewrite as Tiptap editor with AC-1 typed-text displayName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the textarea-based editor with a Tiptap v3 contenteditable. The custom Mention node uses personId/displayName attrs (instead of Tiptap's default id/label) so mentionSerializer round-trips cleanly. AC-1 fix (issue #372): when the user types '@Aug' and selects 'Auguste Raddatz', the mention node stores displayName: 'Aug' (the typed query) — not the person's DB display name. This preserves archival fidelity of the original transcription. The MentionDropdown is mounted imperatively on document.body via Svelte 5's mount(). Its three pieces of dynamic state (items, command, clientRect) are passed as a single $state proxy (model) because Svelte 5's mount() does not return prop accessors. Spec is fully rewritten — all old tests used document.querySelector ('textarea') which is dead after the migration. Co-Authored-By: Claude Opus 4.7 --- .../lib/components/PersonMentionEditor.svelte | 433 +++++++++--------- .../PersonMentionEditor.svelte.spec.ts | 361 ++++++--------- 2 files changed, 344 insertions(+), 450 deletions(-) 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();