test: add e2e tests

This commit is contained in:
Marcel
2026-03-17 13:33:33 +00:00
parent 973620a097
commit 7cb20dec50
20 changed files with 3272 additions and 1376 deletions

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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
View 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}`;
}

View File

@@ -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);

View 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();
});
});

View File

@@ -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' });
});
});