/** * 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, userEvent } from 'vitest/browser'; import { tick } from 'svelte'; import PersonMentionEditorHost from './PersonMentionEditor.test-fixture.svelte'; import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; // Single source of truth for the debounce window — imported from the shared // module so the test cannot drift from production. Sara on PR #629 round 3. import { SEARCH_DEBOUNCE_MS } from './mentionConstants'; type Person = components['schemas']['Person']; type PersonMention = components['schemas']['PersonMention']; /** * Headroom above SEARCH_DEBOUNCE_MS for the debounce-window wait * assertions in this file. 350 ms is calibrated against CI-runner jitter * we observed pre-#629; dropping it below ~200 ms reintroduces flake. * See PR #629 round-2 review comment #10935 (Sara). */ const POST_DEBOUNCE_SLACK_MS = 350; const AUGUSTE: Person = { id: 'p-aug', firstName: 'Auguste', lastName: 'Raddatz', displayName: 'Auguste Raddatz', personType: 'PERSON', familyMember: false, birthYear: 1882, deathYear: 1944 }; const ANNA: Person = { id: 'p-anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt', personType: 'PERSON', familyMember: false, birthYear: 1860 }; function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }) ); } function mockFetchEmpty() { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) ); } type Snapshot = { value: string; mentionedPersons: PersonMention[] }; function renderHost( initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {} ) { let snapshot: Snapshot = { value: initial.value ?? '', mentionedPersons: initial.mentionedPersons ?? [] }; render(PersonMentionEditorHost, { initialValue: initial.value ?? '', initialMentions: initial.mentionedPersons ?? [], disabled: initial.disabled ?? false, onChange: (snap: Snapshot) => { snapshot = snap; } }); return { get snapshot() { return snapshot; } }; } afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); // ─── Rendering ──────────────────────────────────────────────────────────────── describe('PersonMentionEditor — rendering', () => { it('renders the editor as a textbox (ARIA role from editorProps)', async () => { render(PersonMentionEditorHost, { initialValue: '', initialMentions: [], onChange: () => {} }); await expect.element(page.getByRole('textbox')).toBeInTheDocument(); }); it('reflects bound initial value as visible text', async () => { render(PersonMentionEditorHost, { initialValue: 'Hallo Welt', initialMentions: [], onChange: () => {} }); await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument(); }); }); // ─── Typeahead opens on @ ───────────────────────────────────────────────────── describe('PersonMentionEditor — typeahead', () => { it('opens the dropdown when typing @ + query and shows results', async () => { mockFetchWithPersons(); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(async () => { await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); }); }); it('hits /api/persons?q= with the typed query', async () => { const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); }); }); it('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', async () => { const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('limit=5')); }); }); it('shows life dates next to the name in the dropdown', async () => { mockFetchWithPersons(); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(async () => { await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); }); }); it('shows empty state when no persons match', async () => { mockFetchEmpty(); renderHost(); await userEvent.type(page.getByRole('textbox'), '@xyz'); // The visible empty-state

(text-ink-3) shows the copy. The persistent // sr-only aria-live region also contains the same copy, so we scope to the // visible element to avoid a multi-match resolution in expect.element. await vi.waitFor(() => { const visibleEmptyP = document.querySelector( '[role="listbox"] p.text-ink-3' ) as HTMLElement | null; expect(visibleEmptyP).not.toBeNull(); expect(visibleEmptyP!.textContent ?? '').toContain('Keine Personen gefunden'); }); }); it('offers a "create new person" link in the empty state', async () => { mockFetchEmpty(); renderHost(); await userEvent.type(page.getByRole('textbox'), '@xyz'); await vi.waitFor(async () => { const link = page.getByRole('link', { name: /Neue Person anlegen/ }); await expect.element(link).toBeVisible(); await expect.element(link).toHaveAttribute('href', '/persons/new'); }); }); }); // ─── AC-2/3: search input drives the person fetch (debounced) ─────────────── describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => { it('editing the search input fires a debounced fetch with the new query', async () => { const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); // Open the dropdown so the search input is reachable. await userEvent.type(page.getByRole('textbox'), '@'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); const fetchesBeforeSearch = fetchMock.mock.calls.length; // `fill` simulates a single input event with the final value — sidesteps // per-keystroke timing of userEvent.type so the test can deterministically // assert that one input event collapses into one debounced fetch. await page.getByRole('searchbox').fill('Walter'); await vi.waitFor( () => { expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Walter')); }, { timeout: 1000 } ); const fetchesAfterSearch = fetchMock.mock.calls.length - fetchesBeforeSearch; expect(fetchesAfterSearch).toBe(1); }); it('fires exactly one /api/persons fetch when the user searches for Walter (regression guard)', async () => { // Regression guard: a previous version of PersonMentionEditor had a // duplicated `items()` callback in the Tiptap suggestion config that // fetched per-keystroke in addition to the debounced search-input fetch // (Markus & Felix round-1). To catch that regression, we must NOT // subtract any baseline — every fetch from render onwards counts. // Sara on PR #629 round 3. const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); // Open the dropdown, then drive the search input via fill() — sidesteps // per-keystroke timing of userEvent.type that Sara flagged round 2. await userEvent.type(page.getByRole('textbox'), '@'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); await page.getByRole('searchbox').fill('Walter'); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); // No baseline subtraction — count ALL /api/persons fetches since render. // If the legacy per-keystroke items() callback returns, typing `@` alone // would already produce one fetch and `fill('Walter')` another, breaking // this assertion. const personsFetches = fetchMock.mock.calls.filter( ([url]) => typeof url === 'string' && url.startsWith('/api/persons') ); expect(personsFetches.length).toBe(1); }); it('clearing the search input clears the list without firing a fetch', async () => { const fetchMock = vi .fn() .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(async () => { await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); }); const fetchesBeforeClear = fetchMock.mock.calls.length; await userEvent.clear(page.getByRole('searchbox')); // Negative assertion: wait past the debounce window to confirm no // trailing fetch was scheduled. Removing this wait would mask a // re-introduction of the keystroke-driven items() fetch. await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); expect(fetchMock.mock.calls.length).toBe(fetchesBeforeClear); await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); }); }); // ─── Whitespace-only query (Elicit AC-4 ambiguity on PR #629) ─────────────── describe('PersonMentionEditor — whitespace-only query', () => { it('keeps the "Namen eingeben…" prompt and fires no fetch when @ is followed only by spaces', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); await userEvent.type(page.getByRole('textbox'), '@ '); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); // Scope to the visible empty-state

(text-ink-3) — the persistent // sr-only aria-live region above contains the same copy. const visibleEmptyP = document.querySelector( '[role="listbox"] p.text-ink-3' ) as HTMLElement | null; expect(visibleEmptyP).not.toBeNull(); expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt()); expect(fetchMock).not.toHaveBeenCalled(); }); }); // ─── Stale-response race (Sara on PR #629) ─────────────────────────────────── describe('PersonMentionEditor — stale-response race', () => { it('discards a stale response that resolves after the search has been cleared', async () => { let resolveFetch!: (v: { ok: boolean; json: () => Promise }) => void; const pendingResponse = new Promise<{ ok: boolean; json: () => Promise }>((r) => { resolveFetch = r; }); const fetchMock = vi.fn().mockReturnValue(pendingResponse); vi.stubGlobal('fetch', fetchMock); renderHost(); // Open the dropdown and let the debounce fire so a fetch is in flight. await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); }); // Clear the search input *before* the fetch resolves. await userEvent.clear(page.getByRole('searchbox')); await expect.element(page.getByRole('searchbox')).toHaveValue(''); // The stale fetch now resolves with persons. The dropdown must stay empty. resolveFetch({ ok: true, json: () => Promise.resolve([AUGUSTE]) }); // Flush pending Svelte reactivity so any (non-)update from the stale // fetch resolution has landed before we assert. expect.element already // polls, so no fixed-timeout fallback is needed. Sara on PR #629 round 4. await tick(); await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); }); }); // ─── Server failure characterization (Sara #2 on PR #629) ─────────────────── describe('PersonMentionEditor — server failure', () => { it('on 500 response keeps the dropdown open with the empty-state copy (silent failure pinned; distinct error UX tracked separately)', async () => { const fetchMock = vi .fn() .mockResolvedValue({ ok: false, status: 500, json: vi.fn().mockResolvedValue({}) }); vi.stubGlobal('fetch', fetchMock); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); // Pins current silent-failure behaviour. The day someone implements a // distinct error UX (toast / "Suche fehlgeschlagen" copy), this test // goes red and forces them to update the assertion. Scope to the // visible

(text-ink-3) — the persistent sr-only live region // above contains the same copy. const visibleEmptyP = document.querySelector( '[role="listbox"] p.text-ink-3' ) as HTMLElement | null; expect(visibleEmptyP).not.toBeNull(); expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty()); }); it('on a fetch reject (network failure) keeps the dropdown open with the empty-state copy', async () => { const fetchMock = vi.fn().mockRejectedValue(new TypeError('NetworkError')); vi.stubGlobal('fetch', fetchMock); renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); const visibleEmptyP = document.querySelector( '[role="listbox"] p.text-ink-3' ) as HTMLElement | null; expect(visibleEmptyP).not.toBeNull(); expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty()); }); }); // ─── onExit cancels pending debounce (Felix #1 on PR #629) ─────────────────── describe('PersonMentionEditor — onExit cancels pending debounce', () => { it('cancels the pending debounced fetch when Escape closes the dropdown before the debounce fires', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); vi.stubGlobal('fetch', fetchMock); renderHost(); // Open the dropdown by typing @ + a query in the editor. await userEvent.type(page.getByRole('textbox'), '@A'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toBeVisible(); }); // Wait for any in-flight fetch from opening the dropdown to settle. await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); const fetchesBeforeEscape = fetchMock.mock.calls.length; // Trigger a new debounced search (queues runSearch after 150 ms), then // immediately Escape *while focus is back in the editor* so Tiptap's // suggestion-plugin Escape handler fires onExit before the debounce. // Without onExit cancelling the pending debounce, runSearch executes // against the now-unmounted dropdown's state. await page.getByRole('searchbox').fill('Walter'); // Focus the editor so the Escape lands on Tiptap's suggestion handler. (page.getByRole('textbox').element() as HTMLElement).focus(); await userEvent.keyboard('{Escape}'); // Wait past the debounce window. If onExit did not cancel the pending // debounce, a fetch with q=Walter would still fire here. await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape); const walterFetches = newFetches.filter( ([url]) => typeof url === 'string' && url.includes('q=Walter') ); expect(walterFetches.length).toBe(0); }); }); // ─── AC-1: search input prefilled with text typed after @ ─────────────────── describe('PersonMentionEditor — AC-1: search input prefill', () => { it('prefills the dropdown search input with the text typed after @', async () => { mockFetchEmpty(); renderHost(); await userEvent.type(page.getByRole('textbox'), '@WdG'); await vi.waitFor(async () => { await expect.element(page.getByRole('searchbox')).toHaveValue('WdG'); }); }); }); // ─── AC-1: typed text becomes displayName, not DB name ─────────────────────── 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(); // 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(); }); // MentionDropdown handles selection via onmousedown (not onclick) to prevent // blurring the editor before the selection fires. userEvent.click() via CDP // does not reliably trigger Svelte 5's onmousedown handler when TipTap is // mounted — dispatch the MouseEvent directly from browser JS instead. const option = (await page .getByRole('option', { name: /Auguste Raddatz/ }) .element()) as HTMLElement; option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); 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('regression: text value contains the typed query, not the full DB name', 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(() => { // 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(); }); const option = (await page .getByRole('option', { name: /Auguste Raddatz/ }) .element()) as HTMLElement; option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); await vi.waitFor(() => { expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]); }); }); it('clips the inserted displayName to MAX_QUERY_LENGTH=100 chars (Felix #3 on PR #629)', async () => { // CWE-400 amplification: the dropdown clips its search input + mirror at // 100 chars (Nora #1), but the host editor was passing the unclipped // renderProps.query straight through to displayName — so a 105-char // @-suffix in the editor could insert a 105-char displayName into the // sidecar even though the dropdown only searched the first 100. mockFetchWithPersons(); const host = renderHost(); // Type @ + 105 'A' chars in the contenteditable. The renderProps.query // fed into the command callback derives from the editor text after `@`, // not the dropdown's searchbox — so we must drive the editor. await userEvent.type(page.getByRole('textbox'), '@' + 'A'.repeat(105)); // The mocked /api/persons returns AUGUSTE for any query — wait for it. await vi.waitFor(async () => { await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); }); const option = (await page .getByRole('option', { name: /Auguste Raddatz/ }) .element()) as HTMLElement; option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); await vi.waitFor(() => { expect(host.snapshot.mentionedPersons).toHaveLength(1); // Tight assertion: input is 105 chars, cap is exactly 100. Using // `toHaveLength(100)` discriminates "clip works" from "clip works // AND nothing weakened it to e.g. 95". Sara on PR #629 round 4. expect(host.snapshot.mentionedPersons[0].displayName).toHaveLength(100); }); }); it('does not duplicate the sidecar entry when the same person is selected twice', async () => { mockFetchWithPersons(); const host = renderHost({ value: '@Aug ', mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] }); 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).toHaveLength(1); }); }); }); // ─── Keyboard navigation ────────────────────────────────────────────────────── 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(); await userEvent.type(page.getByRole('textbox'), '@A'); await vi.waitFor(async () => { await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); }); await userEvent.keyboard('{ArrowDown}'); await vi.waitFor(async () => { const annaOption = page.getByRole('option', { name: /Anna Schmidt/ }); await expect.element(annaOption).toHaveAttribute('aria-selected', 'true'); }); }); it('Escape closes the dropdown without inserting', async () => { mockFetchWithPersons(); const host = renderHost(); await userEvent.type(page.getByRole('textbox'), '@Aug'); await vi.waitFor(async () => { await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); }); await userEvent.keyboard('{Escape}'); await vi.waitFor(async () => { await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); }); expect(host.snapshot.mentionedPersons).toEqual([]); }); }); // ─── Disabled state (WCAG 2.1.1 — keyboard users) ──────────────────────────── describe('PersonMentionEditor — disabled state', () => { it('sets contenteditable=false on the editor when disabled', async () => { renderHost({ value: 'Bestehender Text', disabled: true }); await vi.waitFor(() => { const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null; expect(textbox).not.toBeNull(); expect(textbox!.getAttribute('contenteditable')).toBe('false'); }); }); it('exposes aria-disabled=true on the editor wrapper when disabled', async () => { renderHost({ disabled: true }); await vi.waitFor(() => { const wrapper = document.querySelector('[aria-disabled="true"]'); expect(wrapper).not.toBeNull(); }); }); it('keeps the editor editable (contenteditable=true) when not disabled', async () => { renderHost({ disabled: false }); await vi.waitFor(() => { const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null; expect(textbox).not.toBeNull(); expect(textbox!.getAttribute('contenteditable')).toBe('true'); }); }); }); // ─── Security — XSS in displayName (CWE-79) ────────────────────────────────── describe('PersonMentionEditor — XSS resistance', () => { it('renders a malicious displayName as text, not as HTML elements', async () => { // A historical sidecar entry whose displayName contains an HTML payload // that would execute if interpolated as raw HTML. Tiptap's renderHTML // returns the @-prefixed string as the third tuple entry, which // ProseMirror's DOMSerializer treats as a Text node — escaping it. const maliciousMention: PersonMention = { personId: '00000000-0000-0000-0000-000000000001', displayName: '' }; renderHost({ value: '@', mentionedPersons: [maliciousMention] }); await vi.waitFor(() => { const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null; expect(textbox).not.toBeNull(); // No element from the malicious payload should have appeared as a real // DOM node. (Tiptap inserts its own ProseMirror-separator in empty // paragraphs — that is internal markup and never carries user attrs; // guard against the injection by checking the user-controlled attrs.) expect(textbox!.querySelector('img[onerror]')).toBeNull(); expect(textbox!.querySelector('img[src="x"]')).toBeNull(); expect(textbox!.querySelector('script')).toBeNull(); // The payload should appear as visible text content instead. expect(textbox!.textContent ?? '').toContain(''); }); }); }); // ─── Placeholder behavior ───────────────────────────────────────────────────── describe('PersonMentionEditor — placeholder behavior', () => { it('sets data-placeholder on the inner element when editor is empty', async () => { render(PersonMentionEditorHost, { initialValue: '', initialMentions: [], placeholder: 'Gib Text ein...', onChange: () => {} }); await vi.waitFor(() => { const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null; expect(inner).not.toBeNull(); expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...'); }); }); it('omits data-placeholder on the inner element when editor has content', async () => { render(PersonMentionEditorHost, { initialValue: 'Bestehender Text', initialMentions: [], placeholder: 'Gib Text ein...', onChange: () => {} }); await vi.waitFor(() => { const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null; expect(inner).not.toBeNull(); expect(inner!.hasAttribute('data-placeholder')).toBe(false); }); }); }); // ─── i18n message content ───────────────────────────────────────────────────── describe('PersonMentionEditor — i18n message content', () => { it('transcription_block_placeholder contains @ mention trigger for discoverability', () => { expect(m.transcription_block_placeholder()).toContain('@'); }); }); // ─── 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(); 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(); expect(option.className).toContain('min-h-[44px]'); }); });