Files
familienarchiv/frontend/src/lib/person/PersonMultiSelect.svelte.spec.ts
Marcel 0c765d8112
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m54s
CI / OCR Service Tests (pull_request) Successful in 33s
CI / Backend Unit Tests (pull_request) Failing after 3m13s
CI / Unit & Component Tests (push) Failing after 3m48s
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 3m24s
fix(tests): fix 13 pre-existing vitest-browser spec failures
Fixes all remaining failing tests in the browser project. Root cause in
every case: Playwright CDP-based clicks/keyboard events do not reliably
trigger Svelte 5 onclick/onkeydown handlers. Pattern applied throughout:

- Buttons / result items: native `.element().click()` or
  `dispatchEvent(new MouseEvent('click', { bubbles: true }))`
- Keyboard events: `dispatchEvent(new KeyboardEvent('keydown', { key }))`
  on the target DOM element
- TipTap selection: `element.focus()` + Selection API +
  `document.dispatchEvent(new Event('selectionchange'))`
- ProseMirror focus for onFocus: `dispatchEvent(new FocusEvent('focus'))`

Also fixes pre-existing content/logic issues found during analysis:
- ChronikErrorCard, BulkDropZone, CorrespondenzHero: stale i18n strings
  and wrong ARIA role (combobox not textbox)
- RichtlinienRuleCard: beide beispielInput + beispielOutput required for
  arrow to render; querySelectorAll to get last code element
- admin/system/page: vi.unstubAllGlobals() in afterEach; strict-mode
  heading selector; per-call mockResolvedValueOnce for dual-card page
- DocumentList: add total prop + result count paragraph (test relied on it)
- PersonTypeahead keyboard navigation: pressKey() helper with native
  KeyboardEvent dispatch replaces userEvent.keyboard()
- PersonMultiSelect: native element clicks for result selection and
  chip removal; keydown dispatch on result div for Enter key test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 13:15:54 +02:00

306 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PersonMultiSelect from './PersonMultiSelect.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
const tick = () => new Promise((r) => setTimeout(r, 0));
const PERSONS = [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
},
{
id: '3',
firstName: 'Karl',
lastName: 'König',
displayName: 'Karl König',
personType: 'PERSON',
familyMember: false
}
];
function mockFetch(persons = PERSONS) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(persons)
})
);
}
function receiverInputs() {
return Array.from(
document.querySelectorAll<HTMLInputElement>('input[type="hidden"][name="receiverIds"]')
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonMultiSelect rendering', () => {
it('renders the text input with placeholder when no persons selected', async () => {
render(PersonMultiSelect, { selectedPersons: [] });
await expect.element(page.getByPlaceholder('Namen tippen...')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-multiselect-empty.png' });
});
it('renders pre-selected persons as chips', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
}
]
});
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/person-multiselect-with-chips.png' });
});
it('renders hidden inputs for each selected person', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
}
]
});
await tick();
const inputs = receiverInputs();
expect(inputs).toHaveLength(2);
expect(inputs[0].value).toBe('1');
expect(inputs[1].value).toBe('2');
});
it('hides the placeholder when persons are selected', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
}
]
});
await expect.element(page.getByPlaceholder('Namen tippen...')).not.toBeInTheDocument();
});
});
// ─── Selecting persons ────────────────────────────────────────────────────────
describe('PersonMultiSelect selecting persons', () => {
it('adds a person chip on result click', async () => {
mockFetch();
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
((await page.getByRole('button', { name: 'Max Mustermann' }).element()) as HTMLElement).click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(input).toHaveValue('');
await page.screenshot({
path: 'test-results/screenshots/person-multiselect-one-selected.png'
});
});
it('can select multiple persons sequentially', async () => {
mockFetch();
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
((await page.getByRole('button', { name: 'Max Mustermann' }).element()) as HTMLElement).click();
await input.fill('Mu');
await waitForDebounce();
(
(await page.getByRole('button', { name: 'Anna Musterfrau' }).element()) as HTMLElement
).click();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
await page.screenshot({
path: 'test-results/screenshots/person-multiselect-two-selected.png'
});
});
it('filters already-selected persons from search results', async () => {
mockFetch();
render(PersonMultiSelect, {
selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
}
]
});
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
// Chip still shows "Max Mustermann" but the dropdown item (role=button) must be filtered out
await expect
.element(page.getByRole('button', { name: 'Max Mustermann' }))
.not.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Anna Musterfrau' })).toBeInTheDocument();
});
it('selects a result with Enter key', async () => {
mockFetch([
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
}
]);
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Ma');
await waitForDebounce();
const resultEl = (await page
.getByRole('button', { name: 'Max Mustermann' })
.element()) as HTMLElement;
resultEl.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
);
await tick();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
});
});
// ─── Removing persons ─────────────────────────────────────────────────────────
describe('PersonMultiSelect removing persons', () => {
it('removes a chip when its × button is clicked', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
}
]
});
// Buttons have aria-label="Entfernen"
const removeBtn = (await page
.getByRole('button', { name: 'Entfernen' })
.first()
.element()) as HTMLElement;
removeBtn.click();
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
});
it('removes the corresponding hidden input when a chip is removed', async () => {
render(PersonMultiSelect, {
selectedPersons: [
{
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
},
{
id: '2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
}
]
});
(
(await page.getByRole('button', { name: 'Entfernen' }).first().element()) as HTMLElement
).click();
await tick();
const inputs = receiverInputs();
expect(inputs).toHaveLength(1);
expect(inputs[0].value).toBe('2');
});
});
// ─── Click outside ────────────────────────────────────────────────────────────
describe('PersonMultiSelect click outside', () => {
it('closes the dropdown when clicking outside', async () => {
mockFetch();
render(PersonMultiSelect, { selectedPersons: [] });
const input = page.getByRole('textbox');
await input.fill('Mu');
await waitForDebounce();
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
document.body.click();
await tick();
await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument();
});
});