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
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>
306 lines
8.8 KiB
TypeScript
306 lines
8.8 KiB
TypeScript
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();
|
||
});
|
||
});
|