Files
familienarchiv/frontend/src/lib/shared/discussion/MentionDropdown.svelte.test.ts
Marcel 4f2880a61a
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m30s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m23s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
a11y(transcription): bump @mention search input to text-base (16 px floor)
The senior-audience body-text floor is 16 px (CLAUDE.md
§Dual-Audience). The search input was the smallest non-metadata
text in the dropdown at text-sm (14 px), even though it is the
primary write surface a 60+ transcriber types into. Bumping to
text-base costs ~2 px of popover header height and closes the
"I can't read what I'm typing" complaint that historically tops
senior-usability tests of search bars. Leonie FINDING-MENTION-006
on PR #629.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 23:28:00 +02:00

367 lines
13 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { flushSync, mount, unmount } from 'svelte';
import MentionDropdown from './MentionDropdown.svelte';
import MentionDropdownFixture from './MentionDropdown.test-fixture.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: Partial<Person> = {}): Person => {
const parts = name.split(' ');
return {
id,
firstName: parts[0],
lastName: parts.slice(1).join(' ') || name,
displayName: name,
personType: 'PERSON',
familyMember: false,
...overrides
};
};
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
});
describe('MentionDropdown', () => {
it('renders the listbox with the mention label', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
await expect.element(page.getByRole('listbox', { name: /person verlinken/i })).toBeVisible();
});
it('shows the "enter a name" prompt when the search field is empty', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
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(), editorQuery: 'unknown' } });
const link = (await page
.getByRole('link', { name: /neue person anlegen/i })
.element()) as HTMLAnchorElement;
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 () => {
render(MentionDropdown, {
props: {
model: baseModel({
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
})
}
});
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
await expect.element(page.getByText('Bert Meier')).toBeVisible();
});
it('marks the first item as aria-selected by default', async () => {
render(MentionDropdown, {
props: { model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] }) }
});
const option = document.querySelector('[role="option"]');
expect(option?.getAttribute('aria-selected')).toBe('true');
});
it('renders the life-date range when birthYear or deathYear is present', async () => {
render(MentionDropdown, {
props: {
model: baseModel({
items: [makePerson('p1', 'Anna', { birthYear: 1899, deathYear: 1972 })]
})
}
});
await expect.element(page.getByText(/1899/)).toBeVisible();
});
it('falls back to a default position when clientRect returns null', async () => {
render(MentionDropdown, {
props: {
model: baseModel({ clientRect: () => null })
}
});
const dropdown = document.querySelector('[role="listbox"]') as HTMLElement;
expect(dropdown.style.left).toBe('0px');
});
it('positions itself based on the clientRect callback', async () => {
render(MentionDropdown, {
props: {
model: baseModel({
clientRect: () => new DOMRect(123, 200, 50, 24)
})
}
});
const dropdown = document.querySelector('[role="listbox"]') as HTMLElement;
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('announces empty-state copy via aria-live="polite" (Leonie FINDING-MENTION-002 on PR #629)', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const listbox = document.querySelector('[role="listbox"]');
expect(listbox).not.toBeNull();
const live = listbox!.querySelector('p[aria-live="polite"]');
expect(live).not.toBeNull();
expect(live!.textContent ?? '').toContain(m.person_mention_search_prompt());
});
it('renders the magnifier icon at h-5 w-5 with text-ink-2 (Leonie BLOCKER on PR #629)', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const icon = document.querySelector('[data-test-search-input]')
?.previousElementSibling as SVGElement | null;
expect(icon).not.toBeNull();
expect(icon!.tagName.toLowerCase()).toBe('svg');
expect(icon!.getAttribute('class') ?? '').toContain('h-5');
expect(icon!.getAttribute('class') ?? '').toContain('w-5');
expect(icon!.getAttribute('class') ?? '').toContain('text-ink-2');
});
it('caps the search input at maxlength=100 (CWE-400 amplification — Nora on PR #629)', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
expect(input).not.toBeNull();
expect(input.maxLength).toBe(100);
});
it('clips a long editorQuery mirror to 100 chars (CWE-400 layered — Nora #1 on PR #629)', async () => {
const longQuery = 'A'.repeat(200);
render(MentionDropdown, { props: { model: baseModel(), editorQuery: longQuery } });
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
expect(input).not.toBeNull();
expect(input.value.length).toBe(100);
expect(input.value).toBe('A'.repeat(100));
});
it('caps the listbox width to the viewport (320 px reflow guard — Leonie FINDING-MENTION-005)', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const listbox = document.querySelector('[role="listbox"]') as HTMLElement;
expect(listbox).not.toBeNull();
expect(listbox.className).toContain('max-w-[calc(100vw-1rem)]');
});
it('renders the @mention search input at text-base (16 px senior-audience floor — Leonie FINDING-MENTION-006)', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
expect(input).not.toBeNull();
expect(input.className).toContain('text-base');
expect(input.className).not.toContain('text-sm');
});
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');
});
});
it('keeps the user-edited search value when editorQuery changes after the takeover (Felix on PR #629)', async () => {
let setEditorQuery!: (q: string) => void;
render(MentionDropdownFixture, {
model: baseModel(),
initialEditorQuery: 'WdG',
onReady: (s: (q: string) => void) => {
setEditorQuery = s;
}
});
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
await page.getByRole('searchbox').fill('Walter');
await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');
setEditorQuery('WdGruyter');
await new Promise((r) => setTimeout(r, 50));
await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');
});
});
// ─── ArrowDown via exported onKeyDown (Sara #3 on PR #629) ──────────────────
//
// In production, Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level
// and forwards them to the dropdown via its exported onKeyDown(event) function
// — the dropdown itself has no DOM keydown listener. This test exercises the
// same export so a regression in highlightedIndex/selection logic is caught
// at the unit level. The full E2E focus-chain test is deferred to a separate
// issue (Playwright).
describe('MentionDropdown — onKeyDown forwarding', () => {
it('ArrowDown advances aria-selected to the next option in the listbox', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const instance = mount(MentionDropdown, {
target: container,
props: {
model: baseModel({
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
})
}
});
try {
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
// First option starts highlighted.
const first = container.querySelector('[data-test-person-id="p1"]') as HTMLElement;
const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement;
expect(first.getAttribute('aria-selected')).toBe('true');
expect(second.getAttribute('aria-selected')).toBe('false');
let consumed = false;
flushSync(() => {
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
});
expect(consumed).toBe(true);
expect(first.getAttribute('aria-selected')).toBe('false');
expect(second.getAttribute('aria-selected')).toBe('true');
} finally {
unmount(instance);
container.remove();
}
});
it('ArrowUp wraps from the first option to the last', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const instance = mount(MentionDropdown, {
target: container,
props: {
model: baseModel({
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
})
}
});
try {
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
let consumed = false;
flushSync(() => {
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
});
expect(consumed).toBe(true);
const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement;
expect(second.getAttribute('aria-selected')).toBe('true');
} finally {
unmount(instance);
container.remove();
}
});
it('Enter invokes model.command with the currently highlighted item', async () => {
const command = vi.fn();
const container = document.createElement('div');
document.body.appendChild(container);
const instance = mount(MentionDropdown, {
target: container,
props: {
model: baseModel({
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')],
command
})
}
});
try {
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
const consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(consumed).toBe(true);
expect(command).toHaveBeenCalledTimes(1);
expect(command.mock.calls[0][0].id).toBe('p1');
} finally {
unmount(instance);
container.remove();
}
});
it('Escape returns false so the suggestion plugin can handle it', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const instance = mount(MentionDropdown, {
target: container,
props: {
model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] })
}
});
try {
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
const consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(consumed).toBe(false);
} finally {
unmount(instance);
container.remove();
}
});
});