Tighten the review-flagged test gaps (no production change):
- submitting state: the old test only asserted the button was initially
enabled (a tautology). Now stub a never-resolving fetch, click Speichern, and
assert the button gains `disabled` — exercising the double-submit guard
(Decision 8).
- the two redirect-throwing save tests now use `await expect(...).rejects` so a
future missing redirect fails loudly instead of being swallowed by try/catch.
- the YEAR end-date-hide assertion uses getByLabelText('Enddatum') not-present,
symmetric with the RANGE reveal, instead of a raw #eventDateEnd querySelector.
- PersonMultiSelect + DocumentMultiSelect: assert the chip remove button carries
the min-h-[44px]/min-w-[44px] target the rtm cites for REQ-017.
Addresses PR #832 review (Tester + Requirements Engineer test-coverage concerns).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
320 lines
9.4 KiB
TypeScript
320 lines
9.4 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) {
|
||
// /api/persons now returns a paged { items } envelope.
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: vi.fn().mockResolvedValue({ items: 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();
|
||
});
|
||
|
||
// REQ-017 (#781): chip remove targets must be ≥44px for the 60+ audience.
|
||
it('renders a ≥44px touch target on the chip remove button', async () => {
|
||
render(PersonMultiSelect, {
|
||
selectedPersons: [{ id: '1', displayName: 'Max Mustermann' }]
|
||
});
|
||
const removeBtn = (await page
|
||
.getByRole('button', { name: 'Entfernen' })
|
||
.first()
|
||
.element()) as HTMLElement;
|
||
expect(removeBtn.className).toContain('min-h-[44px]');
|
||
expect(removeBtn.className).toContain('min-w-[44px]');
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|