Files
familienarchiv/frontend/src/lib/person/PersonMultiSelect.svelte.spec.ts
Marcel d48a89ba5c test(timeline): assert submit-disabled transition and ≥44px chip targets
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>
2026-06-14 00:33:01 +02:00

320 lines
9.4 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) {
// /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();
});
});