diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.spec.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.spec.ts deleted file mode 100644 index 71a7ad61..00000000 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * MentionDropdown — direct component tests. - * - * These tests render the dropdown in isolation, passing the `model` proxy - * (matching what PersonMentionEditor would pass). They cover the dropdown's - * own surface: the search input, the empty-query prompt, and the existing - * "no results" / "create new" behaviors. Wiring tests against Tiptap live - * in PersonMentionEditor.svelte.spec.ts. - */ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { cleanup, render } from 'vitest-browser-svelte'; -import { page, userEvent } from 'vitest/browser'; -import MentionDropdown from './MentionDropdown.svelte'; -import type { components } from '$lib/generated/api'; -import { m } from '$lib/paraglide/messages.js'; - -type Person = components['schemas']['Person']; - -type DropdownState = { - items: Person[]; - command: (item: Person) => void; - clientRect: (() => DOMRect | null) | null; -}; - -function makeModel(items: Person[] = []): DropdownState { - return { - items, - command: () => {}, - clientRect: () => new DOMRect(0, 0, 0, 0) - }; -} - -afterEach(() => cleanup()); - -describe('MentionDropdown — search input', () => { - it('renders a search input pre-filled with the editorQuery prop', async () => { - render(MentionDropdown, { - model: makeModel(), - editorQuery: 'WdG', - onSearch: () => {} - }); - - await expect.element(page.getByRole('searchbox')).toHaveValue('WdG'); - }); - - it('exposes a data-test-search-input attribute for E2E selectors', async () => { - render(MentionDropdown, { - model: makeModel(), - editorQuery: '', - onSearch: () => {} - }); - - const input = document.querySelector('[data-test-search-input]'); - expect(input).not.toBeNull(); - expect((input as HTMLInputElement).type).toBe('search'); - }); - - it('shows "enter a name" prompt when search is empty (not "no results")', async () => { - render(MentionDropdown, { - model: makeModel([]), - editorQuery: '', - onSearch: () => {} - }); - - 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 results" when search has a query but the list is empty', async () => { - render(MentionDropdown, { - model: makeModel([]), - editorQuery: 'WdG', - onSearch: () => {} - }); - - await expect.element(page.getByText(m.person_mention_popup_empty())).toBeVisible(); - await expect.element(page.getByText(m.person_mention_search_prompt())).not.toBeInTheDocument(); - }); - - it('"create new person" link has rel="noopener noreferrer" (CWE-116)', async () => { - render(MentionDropdown, { - model: makeModel([]), - editorQuery: 'unknown', // non-empty so the empty-state link renders - onSearch: () => {} - }); - - const link = document.querySelector('a[href="/persons/new"]') as HTMLAnchorElement; - expect(link).not.toBeNull(); - expect(link.getAttribute('rel')).toContain('noopener'); - expect(link.getAttribute('rel')).toContain('noreferrer'); - }); - - it('search input wrapper meets the 44px touch target (WCAG 2.2 AA)', async () => { - render(MentionDropdown, { - model: makeModel(), - editorQuery: '', - onSearch: () => {} - }); - - const input = document.querySelector('[data-test-search-input]') as HTMLElement; - expect(input).not.toBeNull(); - expect(input.className).toContain('min-h-[44px]'); - }); - - it('invokes onSearch with the current value whenever the user types', async () => { - const onSearch = vi.fn(); - render(MentionDropdown, { - model: makeModel(), - editorQuery: '', - onSearch - }); - - await userEvent.type(page.getByRole('searchbox'), 'Walter'); - - await vi.waitFor(() => { - expect(onSearch).toHaveBeenCalled(); - expect(onSearch).toHaveBeenLastCalledWith('Walter'); - }); - }); -}); diff --git a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts index 46e2da86..72de37ca 100644 --- a/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts +++ b/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts @@ -1,22 +1,35 @@ 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 MentionDropdown from './MentionDropdown.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: Record = {}) => ({ - id, - firstName: name.split(' ')[0] ?? null, - lastName: name.split(' ').slice(1).join(' ') || name, - displayName: name, - birthYear: null as number | null, - deathYear: null as number | null, - ...overrides -}); +const makePerson = (id: string, name: string, overrides: Partial = {}): Person => + ({ + id, + firstName: name.split(' ')[0] ?? null, + lastName: name.split(' ').slice(1).join(' ') || name, + displayName: name, + personType: 'PERSON', + familyMember: false, + birthYear: null, + deathYear: null, + ...overrides + }) as unknown as Person; -const baseModel = (overrides: Record = {}) => ({ - items: [] as ReturnType[], +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 @@ -29,14 +42,22 @@ describe('MentionDropdown', () => { await expect.element(page.getByRole('listbox', { name: /person verlinken/i })).toBeVisible(); }); - it('renders the empty placeholder when items is empty', async () => { + it('shows the "enter a name" prompt when the search field is empty', async () => { render(MentionDropdown, { props: { model: baseModel() } }); - await expect.element(page.getByText('Keine Personen gefunden')).toBeVisible(); + 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() } }); + render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'unknown' } }); const link = (await page .getByRole('link', { name: /neue person anlegen/i }) @@ -44,6 +65,7 @@ describe('MentionDropdown', () => { 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 () => { @@ -104,3 +126,43 @@ describe('MentionDropdown', () => { 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('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'); + }); + }); +});