Tester #5506 §1: 14 tests × 250ms real-timer waits = 3.5s wall-clock, also racing the 200ms internal debounce by only 50ms — a flake on a busy CI runner. Switch to vi.useFakeTimers + advanceTimersByTimeAsync; test execution now 236ms (was 3.08s), determinism guaranteed because the debounce runs against the fake clock. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, 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'];
|
||
|
||
// Editor's internal search debounce is 200ms — drive it via fake timers
|
||
// so tests are deterministic and fast (Tester #5506 §1).
|
||
const DEBOUNCE_MS = 200;
|
||
|
||
async function flushDebounce() {
|
||
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||
// Let the awaited fetch resolve and the resulting state assignments flush.
|
||
await vi.runAllTimersAsync();
|
||
}
|
||
|
||
async function tick() {
|
||
await vi.advanceTimersByTimeAsync(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;
|
||
}
|
||
};
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.unstubAllGlobals();
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
// ─── 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 flushDebounce();
|
||
|
||
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 flushDebounce();
|
||
|
||
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 flushDebounce();
|
||
|
||
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 flushDebounce();
|
||
|
||
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 flushDebounce();
|
||
|
||
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 flushDebounce();
|
||
|
||
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 flushDebounce();
|
||
|
||
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 flushDebounce();
|
||
|
||
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 flushDebounce();
|
||
|
||
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 flushDebounce();
|
||
|
||
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 flushDebounce();
|
||
|
||
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 flushDebounce();
|
||
|
||
const option = document.querySelector('[role="option"]') as HTMLElement;
|
||
expect(option).not.toBeNull();
|
||
expect(option.className).toContain('min-h-[44px]');
|
||
});
|
||
});
|