refactor(transcription): consolidate MentionDropdown test files

For issue #380. Drops the redundant MentionDropdown.svelte.spec.ts that
was added earlier in this branch and folds its search-input coverage
into the long-established MentionDropdown.svelte.test.ts. Same
test surface, single file.

While there:
- Updates the empty-state test to match the new behaviour: an empty
  search field shows the "Namen eingeben…" prompt; "Keine Personen
  gefunden" only appears when a query is entered but nothing matches.
- Fixes pre-existing Person-type drift in makePerson (missing
  personType, familyMember).
- Stricten the create-new link rel assertion to cover the new
  noreferrer addition.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-19 21:29:13 +02:00
committed by marcel
parent 96e8a07a8c
commit 93e58be141
2 changed files with 77 additions and 135 deletions

View File

@@ -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');
});
});
});

View File

@@ -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<string, unknown> = {}) => ({
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> = {}): 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<string, unknown> = {}) => ({
items: [] as ReturnType<typeof makePerson>[],
type DropdownState = {
items: Person[];
command: (item: Person) => void;
clientRect: (() => DOMRect | null) | null;
};
const baseModel = (overrides: Partial<DropdownState> = {}): 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');
});
});
});