feat(transcription): add PersonMentionEditor with typeahead + keyboard nav
Mirrors MentionEditor for users but searches /api/persons?q=, allows
multi-word queries (delegated to detectPersonMention), displays life
dates next to each result, and uses min-h-[44px] rows for WCAG 2.2 AA
touch targets. Selection writes both the @DisplayName text and a
{personId, displayName} sidecar entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
348
frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
Normal file
348
frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } 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'];
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 250));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const AUGUSTE: Person = {
|
||||
id: 'p-aug',
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
} as unknown as Person;
|
||||
|
||||
const ANNA: Person = {
|
||||
id: 'p-anna',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Schmidt',
|
||||
displayName: 'Anna Schmidt',
|
||||
birthYear: 1860
|
||||
} 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;
|
||||
}
|
||||
|
||||
function mockFetchEmpty() {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
|
||||
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 }));
|
||||
}
|
||||
|
||||
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
||||
|
||||
function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) {
|
||||
let snapshot: Snapshot = {
|
||||
value: initial.value ?? '',
|
||||
mentionedPersons: initial.mentionedPersons ?? []
|
||||
};
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: initial.value ?? '',
|
||||
initialMentions: initial.mentionedPersons ?? [],
|
||||
onChange: (snap: Snapshot) => {
|
||||
snapshot = snap;
|
||||
}
|
||||
});
|
||||
return {
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — rendering', () => {
|
||||
it('renders the textarea with placeholder', async () => {
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: '',
|
||||
initialMentions: [],
|
||||
placeholder: 'Transkription…',
|
||||
onChange: () => {}
|
||||
});
|
||||
await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects bound initial value', async () => {
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: 'Hallo Welt',
|
||||
initialMentions: [],
|
||||
onChange: () => {}
|
||||
});
|
||||
await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — typeahead', () => {
|
||||
it('opens the popup 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 waitForDebounce();
|
||||
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hits /api/persons?q= with the typed query', async () => {
|
||||
const fetchMock = mockFetchWithPersons();
|
||||
renderHost();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
await waitForDebounce();
|
||||
|
||||
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 waitForDebounce();
|
||||
|
||||
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 waitForDebounce();
|
||||
|
||||
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 waitForDebounce();
|
||||
|
||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selection writes text + sidecar ─────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — selecting a person', () => {
|
||||
it('inserts @DisplayName followed by a trailing space into the textarea', 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 waitForDebounce();
|
||||
|
||||
clickOption('p-aug');
|
||||
await tick();
|
||||
|
||||
expect(host.snapshot.value).toBe('@Auguste Raddatz ');
|
||||
});
|
||||
|
||||
it('pushes {personId, displayName} into the bound mentionedPersons array', 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 waitForDebounce();
|
||||
|
||||
clickOption('p-aug');
|
||||
await tick();
|
||||
|
||||
expect(host.snapshot.mentionedPersons).toEqual([
|
||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
|
||||
]);
|
||||
});
|
||||
|
||||
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' }]
|
||||
});
|
||||
|
||||
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 waitForDebounce();
|
||||
|
||||
clickOption('p-aug');
|
||||
await tick();
|
||||
|
||||
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keyboard navigation (B11b) ──────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — keyboard navigation (B11b)', () => {
|
||||
it('ArrowDown / ArrowUp cycle the highlighted 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 waitForDebounce();
|
||||
|
||||
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;
|
||||
|
||||
expect(optAuguste.getAttribute('aria-selected')).toBe('true');
|
||||
expect(optAnna.getAttribute('aria-selected')).toBe('false');
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('Enter selects the currently highlighted result', 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 waitForDebounce();
|
||||
|
||||
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
await tick();
|
||||
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await tick();
|
||||
|
||||
expect(host.snapshot.mentionedPersons).toEqual([
|
||||
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('Escape closes the popup without inserting anything', 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 waitForDebounce();
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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();
|
||||
|
||||
const ta = getTextarea();
|
||||
ta.focus();
|
||||
ta.value = '@Aug';
|
||||
ta.selectionStart = 4;
|
||||
ta.selectionEnd = 4;
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await waitForDebounce();
|
||||
|
||||
const option = document.querySelector('[role="option"]') as HTMLElement;
|
||||
expect(option).not.toBeNull();
|
||||
expect(option.className).toContain('min-h-[44px]');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user