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}`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { isoToGerman, germanToIso } from '$lib/utils';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -12,19 +13,6 @@
|
||||
let senderId = $state(doc.sender?.id ?? '');
|
||||
let selectedReceivers = $state(doc.receivers ?? []);
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
let dateDisplay = $state(isoToGerman(doc.documentDate ?? ''));
|
||||
let dateIso = $state(doc.documentDate ?? '');
|
||||
let dateDirty = $state(false);
|
||||
|
||||
87
frontend/src/routes/login/page.svelte.spec.ts
Normal file
87
frontend/src/routes/login/page.svelte.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import LoginPage from './+page.svelte';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe('Login page – rendering', () => {
|
||||
it('renders the page title', async () => {
|
||||
render(LoginPage, {});
|
||||
await expect.element(page.getByText('Familienarchiv')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/login-default.png' });
|
||||
});
|
||||
|
||||
it('renders the submit button', async () => {
|
||||
render(LoginPage, {});
|
||||
await expect.element(page.getByRole('button', { name: 'Anmelden' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the username input', async () => {
|
||||
render(LoginPage, {});
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="username"]');
|
||||
expect(input).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the password input', async () => {
|
||||
render(LoginPage, {});
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
|
||||
expect(input).not.toBeNull();
|
||||
});
|
||||
|
||||
it('username field is required', async () => {
|
||||
render(LoginPage, {});
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="username"]');
|
||||
expect(input?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('password field is required', async () => {
|
||||
render(LoginPage, {});
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
|
||||
expect(input?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('password field has type="password"', async () => {
|
||||
render(LoginPage, {});
|
||||
await tick();
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="password"]');
|
||||
expect(input?.type).toBe('password');
|
||||
});
|
||||
|
||||
it('form submits to the login action', async () => {
|
||||
render(LoginPage, {});
|
||||
await tick();
|
||||
const form = document.querySelector<HTMLFormElement>('form');
|
||||
expect(form?.action).toMatch(/\?\/login$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login page – error state', () => {
|
||||
it('shows no error when form is undefined', async () => {
|
||||
render(LoginPage, {});
|
||||
await tick();
|
||||
expect(document.querySelector('.text-red-600')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no error when form has no error property', async () => {
|
||||
render(LoginPage, { form: {} });
|
||||
await tick();
|
||||
expect(document.querySelector('.text-red-600')).toBeNull();
|
||||
});
|
||||
|
||||
it('displays the error message from the form action', async () => {
|
||||
render(LoginPage, { form: { error: 'Ungültige Anmeldedaten.' } });
|
||||
await expect.element(page.getByText('Ungültige Anmeldedaten.')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/login-error.png' });
|
||||
});
|
||||
|
||||
it('applies red styling to the error text', async () => {
|
||||
render(LoginPage, { form: { error: 'Fehler!' } });
|
||||
await tick();
|
||||
expect(document.querySelector('.text-red-600')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,172 @@
|
||||
import { page } from 'vitest/browser';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
it('should render h1', async () => {
|
||||
render(Page);
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 });
|
||||
await expect.element(heading).toBeInTheDocument();
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
// Silence fetch calls from PersonTypeahead when advanced filters are open
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||
);
|
||||
|
||||
// ─── Test data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const emptyData = {
|
||||
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
||||
documents: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
error: null
|
||||
};
|
||||
|
||||
const makeDoc = (overrides = {}) => ({
|
||||
id: '1',
|
||||
title: 'Testbrief',
|
||||
originalFilename: 'testbrief.pdf',
|
||||
status: 'UPLOADED',
|
||||
documentDate: '2024-03-15',
|
||||
location: 'Berlin',
|
||||
sender: { id: 'p1', firstName: 'Max', lastName: 'Mustermann' },
|
||||
receivers: [{ id: 'p2', firstName: 'Anna', lastName: 'Musterfrau' }],
|
||||
tags: [{ name: 'Familie' }],
|
||||
filePath: '/files/testbrief.pdf',
|
||||
...overrides
|
||||
});
|
||||
|
||||
const dataWithDocs = { ...emptyData, documents: [makeDoc()] };
|
||||
|
||||
// ─── Search bar ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – search bar', () => {
|
||||
it('renders the full-text search input', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
await expect
|
||||
.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...'))
|
||||
.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/home-default.png' });
|
||||
});
|
||||
|
||||
it('renders the filter toggle button', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
// Use exact match to avoid collision with the empty-state "Alle Filter löschen" button
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Filter', exact: true }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the reset link pointing to /', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
const resetLink = page.getByTitle('Filter zurücksetzen');
|
||||
await expect.element(resetLink).toBeInTheDocument();
|
||||
await expect.element(resetLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('pre-fills the search input from filters.q', async () => {
|
||||
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
|
||||
await expect.element(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toHaveValue('Urlaub');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Advanced filters ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – advanced filters', () => {
|
||||
it('hides the advanced filters by default', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
// Date inputs are inside the {#if showAdvanced} block → not in DOM
|
||||
await tick();
|
||||
expect(document.querySelector('input[id="from"]')).toBeNull();
|
||||
expect(document.querySelector('input[id="to"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('toggles the advanced filter panel open on button click', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
await page.getByRole('button', { name: 'Filter', exact: true }).click();
|
||||
await tick();
|
||||
expect(document.querySelector('input[id="from"]')).not.toBeNull();
|
||||
expect(document.querySelector('input[id="to"]')).not.toBeNull();
|
||||
await page.screenshot({ path: 'test-results/screenshots/home-filters-open.png' });
|
||||
});
|
||||
|
||||
it('collapses the advanced filter panel on second click', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
const btn = page.getByRole('button', { name: 'Filter', exact: true });
|
||||
await btn.click();
|
||||
// Wait for the input to appear before clicking again
|
||||
await expect.element(page.getByText('Schlagworte')).toBeInTheDocument();
|
||||
await btn.click();
|
||||
// Wait for slide transition to finish
|
||||
await expect.element(page.getByText('Schlagworte')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the tag filter section when filters are open', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
await page.getByRole('button', { name: 'Filter', exact: true }).click();
|
||||
await expect.element(page.getByText('Schlagworte')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Document list ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – document list', () => {
|
||||
it('shows empty state when there are no documents', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
await expect.element(page.getByText('Keine Dokumente gefunden')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/home-empty-state.png' });
|
||||
});
|
||||
|
||||
it('renders a document with title, date, location, sender and receiver', async () => {
|
||||
render(Page, { data: dataWithDocs });
|
||||
await expect.element(page.getByText('Testbrief')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('15. März 2024')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/home-with-documents.png' });
|
||||
});
|
||||
|
||||
it('renders a tag chip for each document tag', async () => {
|
||||
render(Page, { data: dataWithDocs });
|
||||
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Unbekannt" for sender when sender is null', async () => {
|
||||
const data = { ...emptyData, documents: [makeDoc({ sender: null })] };
|
||||
render(Page, { data });
|
||||
await expect.element(page.getByText('Unbekannt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders original filename when title is empty', async () => {
|
||||
const data = { ...emptyData, documents: [makeDoc({ title: null })] };
|
||||
render(Page, { data });
|
||||
await expect.element(page.getByText('testbrief.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links each document to its detail page', async () => {
|
||||
render(Page, { data: dataWithDocs });
|
||||
const link = page.getByRole('link', { name: /Testbrief/ });
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/1');
|
||||
});
|
||||
|
||||
it('renders the "Neues Dokument" link', async () => {
|
||||
render(Page, { data: emptyData });
|
||||
const link = page.getByRole('link', { name: /Neues Dokument/i });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/new');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – error state', () => {
|
||||
it('shows the error message when data.error is set', async () => {
|
||||
const data = { ...emptyData, error: 'Daten konnten nicht geladen werden.' };
|
||||
render(Page, { data });
|
||||
await expect
|
||||
.element(page.getByText('Daten konnten nicht geladen werden.'))
|
||||
.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user