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
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>
367 lines
13 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|