test: add e2e tests
This commit is contained in:
184
frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts
Normal file
184
frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { 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' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' },
|
||||
{ id: '3', firstName: 'Karl', lastName: 'König' }
|
||||
];
|
||||
|
||||
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(() => {
|
||||
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' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
]
|
||||
});
|
||||
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' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
]
|
||||
});
|
||||
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' }]
|
||||
});
|
||||
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.getByText('Mustermann, Max').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.getByText('Mustermann, Max').click();
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Musterfrau, Anna').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' }]
|
||||
});
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects a result with Enter key', async () => {
|
||||
mockFetch([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
|
||||
render(PersonMultiSelect, { selectedPersons: [] });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
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' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
]
|
||||
});
|
||||
// Buttons have aria-label="Entfernen"
|
||||
const removeButtons = page.getByRole('button', { name: 'Entfernen' });
|
||||
await removeButtons.first().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' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
]
|
||||
});
|
||||
await page.getByRole('button', { name: 'Entfernen' }).first().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('Mustermann, Max')).toBeInTheDocument();
|
||||
document.body.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
196
frontend/src/lib/components/PersonTypeahead.svelte.spec.ts
Normal file
196
frontend/src/lib/components/PersonTypeahead.svelte.spec.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import PersonTypeahead from './PersonTypeahead.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' },
|
||||
{ id: '2', firstName: 'Anna', lastName: 'Musterfrau' }
|
||||
];
|
||||
|
||||
function mockFetchWithPersons(persons = PERSONS) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(persons)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchFailure() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
}
|
||||
|
||||
function hiddenInput(name: string) {
|
||||
return document.querySelector<HTMLInputElement>(`input[type="hidden"][name="${name}"]`);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – rendering', () => {
|
||||
it('renders the label and text input', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
await expect.element(page.getByText('Absender')).toBeInTheDocument();
|
||||
await expect.element(page.getByPlaceholder('Namen tippen...')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-empty.png' });
|
||||
});
|
||||
|
||||
it('pre-fills the visible input from initialName', async () => {
|
||||
render(PersonTypeahead, {
|
||||
name: 'senderId',
|
||||
label: 'Absender',
|
||||
initialName: 'Max Mustermann'
|
||||
});
|
||||
// The $effect that syncs initialName runs after mount — poll until the value appears
|
||||
await expect.element(page.getByPlaceholder('Namen tippen...')).toHaveValue('Max Mustermann');
|
||||
});
|
||||
|
||||
it('renders a hidden input with the correct name attribute', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hidden input starts with the provided value', async () => {
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender', value: '42' });
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('42');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – search', () => {
|
||||
it('opens the dropdown with results after typing', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Musterfrau, Anna')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-open.png' });
|
||||
});
|
||||
|
||||
it('shows loading indicator while fetching', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Suche...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no dropdown when the search returns empty results', async () => {
|
||||
mockFetchWithPersons([]);
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('XYZ');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Suche...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no results when fetch fails', async () => {
|
||||
mockFetchFailure();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selection ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – selection', () => {
|
||||
it('fills the visible input and closes dropdown on click', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' });
|
||||
});
|
||||
|
||||
it('sets the hidden input value to the selected person id', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('1');
|
||||
});
|
||||
|
||||
it('calls onchange with the person id on selection', async () => {
|
||||
mockFetchWithPersons();
|
||||
const onchange = vi.fn();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('selects a result with Enter key', async () => {
|
||||
mockFetchWithPersons([{ id: '1', firstName: 'Max', lastName: 'Mustermann' }]);
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Ma');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
await expect.element(input).toHaveValue('Max Mustermann');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Clearing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – clearing a selection', () => {
|
||||
it('clears the hidden value when user edits the visible input after a selection', async () => {
|
||||
mockFetchWithPersons();
|
||||
const onchange = vi.fn();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender', onchange });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await page.getByText('Mustermann, Max').click();
|
||||
expect(onchange).toHaveBeenCalledWith('1');
|
||||
onchange.mockClear();
|
||||
|
||||
await input.fill('x');
|
||||
await waitForDebounce();
|
||||
expect(onchange).toHaveBeenCalledWith('');
|
||||
await tick();
|
||||
expect(hiddenInput('senderId')?.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Click outside ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PersonTypeahead – click outside', () => {
|
||||
it('closes the dropdown when clicking outside the component', async () => {
|
||||
mockFetchWithPersons();
|
||||
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
|
||||
const input = page.getByPlaceholder('Namen tippen...');
|
||||
await input.fill('Mu');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument();
|
||||
document.body.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
213
frontend/src/lib/components/TagInput.svelte.spec.ts
Normal file
213
frontend/src/lib/components/TagInput.svelte.spec.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import TagInput from './TagInput.svelte';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
function mockFetchWithTags(tagNames: string[]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(tagNames.map((name) => ({ name })))
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchEmpty() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – rendering', () => {
|
||||
it('shows creation placeholder when allowCreation=true and no tags', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Schlagworte hinzufügen...'))
|
||||
.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-empty.png' });
|
||||
});
|
||||
|
||||
it('shows filter placeholder when allowCreation=false', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Nach Schlagworten filtern...'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders existing tags as chips', async () => {
|
||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-with-chips.png' });
|
||||
});
|
||||
|
||||
it('hides input placeholder once tags exist', async () => {
|
||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await expect.element(input).toHaveAttribute('placeholder', '');
|
||||
});
|
||||
|
||||
it('shows the "Enter" hint when allowCreation=true', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
await expect.element(page.getByText(/Enter drücken/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the "Enter" hint when allowCreation=false', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
await expect.element(page.getByText(/Enter drücken/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Adding tags ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – adding tags', () => {
|
||||
it('adds a tag on Enter and clears the input', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Urlaubsreise');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('Urlaubsreise')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
});
|
||||
|
||||
it('trims whitespace from the new tag', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill(' Leerzeichen ');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('Leerzeichen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add a duplicate tag', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Familie');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(input).toHaveValue('');
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add an arbitrary tag when allowCreation=false', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('UnbekannterTag');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('UnbekannterTag')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Removing tags ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – removing tags', () => {
|
||||
it('removes a chip when its × button is clicked', async () => {
|
||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
||||
// The × buttons have aria-label="Schlagwort entfernen"
|
||||
const removeButtons = page.getByRole('button', { name: 'Schlagwort entfernen' });
|
||||
await removeButtons.first().click();
|
||||
await expect.element(page.getByText('Familie')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-after-remove.png' });
|
||||
});
|
||||
|
||||
it('removes the last tag on Backspace when the input is empty', async () => {
|
||||
render(TagInput, { tags: ['Familie', 'Krieg'], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.click();
|
||||
await userEvent.keyboard('{Backspace}');
|
||||
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not remove a tag on Backspace when the input has text', async () => {
|
||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('x');
|
||||
await userEvent.keyboard('{Backspace}');
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Autocomplete ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – autocomplete', () => {
|
||||
it('shows suggestions after typing 2+ characters', async () => {
|
||||
mockFetchWithTags(['Familie', 'Freunde']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestions.png' });
|
||||
});
|
||||
|
||||
it('does not call fetch for fewer than 2 characters', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('F');
|
||||
await waitForDebounce();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters already-selected tags out of suggestions', async () => {
|
||||
mockFetchWithTags(['Familie', 'Freunde']);
|
||||
render(TagInput, { tags: ['Familie'], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fr');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
||||
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects a suggestion on click and adds it as a chip', async () => {
|
||||
mockFetchWithTags(['Familie', 'Freunde']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForDebounce();
|
||||
await page.getByRole('option', { name: 'Familie' }).click();
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestion-selected.png' });
|
||||
});
|
||||
|
||||
it('navigates suggestions with ArrowDown and selects with Enter', async () => {
|
||||
mockFetchWithTags(['Aachen', 'Berlin', 'Celle']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('__');
|
||||
await waitForDebounce();
|
||||
await userEvent.keyboard('{ArrowDown}'); // index 0 → Aachen
|
||||
await userEvent.keyboard('{ArrowDown}'); // index 1 → Berlin
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the dropdown when clicking outside the component', async () => {
|
||||
mockFetchWithTags(['Familie']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForDebounce();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
|
||||
document.body.click();
|
||||
await tick();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
80
frontend/src/lib/utils.spec.ts
Normal file
80
frontend/src/lib/utils.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { germanToIso, isoToGerman } from './utils';
|
||||
|
||||
describe('isoToGerman', () => {
|
||||
it('converts a standard ISO date', () => {
|
||||
expect(isoToGerman('2024-03-15')).toBe('15.03.2024');
|
||||
});
|
||||
|
||||
it('preserves leading zeros for day and month', () => {
|
||||
expect(isoToGerman('2024-01-05')).toBe('05.01.2024');
|
||||
});
|
||||
|
||||
it('handles December 31', () => {
|
||||
expect(isoToGerman('1945-12-31')).toBe('31.12.1945');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(isoToGerman('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for plain text', () => {
|
||||
expect(isoToGerman('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for partial ISO string', () => {
|
||||
expect(isoToGerman('2024-03')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for ISO with time component', () => {
|
||||
expect(isoToGerman('2024-03-15T12:00:00')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('germanToIso', () => {
|
||||
it('converts a standard German date', () => {
|
||||
expect(germanToIso('15.03.2024')).toBe('2024-03-15');
|
||||
});
|
||||
|
||||
it('preserves leading zeros for day and month', () => {
|
||||
expect(germanToIso('05.01.2024')).toBe('2024-01-05');
|
||||
});
|
||||
|
||||
it('handles December 31', () => {
|
||||
expect(germanToIso('31.12.1945')).toBe('1945-12-31');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(germanToIso('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for plain text', () => {
|
||||
expect(germanToIso('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for date without leading zeros', () => {
|
||||
expect(germanToIso('5.3.2024')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for ISO format input', () => {
|
||||
expect(germanToIso('2024-03-15')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for partial German date', () => {
|
||||
expect(germanToIso('15.03')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip conversion', () => {
|
||||
const dates = ['2024-03-15', '1945-01-01', '2000-12-31', '1899-07-04'];
|
||||
|
||||
for (const date of dates) {
|
||||
it(`ISO → German → ISO is identity for ${date}`, () => {
|
||||
expect(germanToIso(isoToGerman(date))).toBe(date);
|
||||
});
|
||||
}
|
||||
|
||||
it('German → ISO → German is identity', () => {
|
||||
expect(isoToGerman(germanToIso('20.04.1889'))).toBe('20.04.1889');
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/utils.ts
Normal file
20
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
|
||||
* Returns an empty string for invalid or empty input.
|
||||
*/
|
||||
export function isoToGerman(iso: string): string {
|
||||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
|
||||
const [y, m, d] = iso.split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a German date string (DD.MM.YYYY) to ISO format (YYYY-MM-DD).
|
||||
* Returns an empty string for invalid or empty input.
|
||||
*/
|
||||
export function germanToIso(german: string): string {
|
||||
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||
if (!match) return '';
|
||||
const [, d, m, y] = match;
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
Reference in New Issue
Block a user