Files
familienarchiv/frontend/src/routes/page.svelte.spec.ts
Marcel d900480920 feat(#221): add parent selector and color picker to admin tag edit form
Tag edit form gains a parent <select> listing all other tags (self
excluded) and a 10-swatch color picker that is only shown when no
parent is selected. Submitting passes parentId and color to the PUT
/api/tags/{id} endpoint via TagUpdateDTO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 16:39:02 +02:00

310 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
const tick = () => new Promise((r) => setTimeout(r, 0));
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
// Silence fetch calls from PersonTypeahead when advanced filters are open
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
);
afterEach(cleanup);
// ─── Test data ────────────────────────────────────────────────────────────────
const emptyData = {
user: undefined,
canWrite: true,
canAnnotate: false,
isDashboard: false,
filters: {
q: '',
from: '',
to: '',
senderId: '',
receiverId: '',
tags: [],
sort: 'DATE' as const,
dir: 'desc' as const,
tagQ: '',
tagOp: 'AND'
},
documents: [],
total: 0,
matchData: {} as Record<
string,
import('$lib/generated/api').components['schemas']['SearchMatchData']
>,
incompleteDocs: [],
recentDocs: [],
stats: null,
incompleteCount: 0,
initialValues: { senderName: '', receiverName: '' },
segmentationDocs: [],
transcriptionDocs: [],
readyDocs: [],
weeklyStats: null,
error: null
};
const makeDoc = (overrides = {}) => ({
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED' as const,
documentDate: '2024-03-15',
location: 'Berlin',
sender: {
id: 'p1',
firstName: 'Max',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON' as const
},
receivers: [
{
id: 'p2',
firstName: 'Anna',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON' as const
}
],
tags: [{ id: 't1', name: 'Familie' }],
filePath: '/files/testbrief.pdf',
createdAt: '2024-03-15T10:00:00Z',
updatedAt: '2024-03-15T10:00:00Z',
...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('Titel, Personen, Tags durchsuchen\u2026'))
.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('Titel, Personen, Tags durchsuchen\u2026'))
.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');
});
});
// ─── Keystroke preservation (issue #34) ──────────────────────────────────────
describe('Home page search input keystroke preservation', () => {
it('does not overwrite the search input while the user is focused and stale data arrives', async () => {
const { rerender } = render(Page, { data: emptyData });
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026');
// User types "abc" — input is focused
await input.click();
await input.fill('abc');
// Simulate a navigation completing with stale data (q='a') while the user is still typing
await rerender({ data: { ...emptyData, filters: { ...emptyData.filters, q: 'a' } } });
await tick();
// Input must still show what the user typed, not the stale URL value
await expect.element(input).toHaveValue('abc');
});
});
// ─── Dashboard mode ───────────────────────────────────────────────────────────
describe('Home page dashboard mode', () => {
const dashboardData = {
...emptyData,
isDashboard: true,
canWrite: false,
incompleteDocs: [],
recentDocs: []
};
it('hides the right column when canWrite is false and incompleteDocs is empty', async () => {
render(Page, { data: dashboardData });
const rightCol = page.getByTestId('dashboard-right-column');
await expect.element(rightCol).not.toBeInTheDocument();
});
it('shows the right column when canWrite is true', async () => {
render(Page, { data: { ...dashboardData, canWrite: true } });
const rightCol = page.getByTestId('dashboard-right-column');
await expect.element(rightCol).toBeInTheDocument();
});
it('shows the right column when incompleteDocs is non-empty', async () => {
render(Page, {
data: {
...dashboardData,
canWrite: false,
incompleteDocs: [{ id: 'd1', title: 'Taufschein' }]
}
});
const rightCol = page.getByTestId('dashboard-right-column');
await expect.element(rightCol).toBeInTheDocument();
});
});
// ─── 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' });
});
});
// ─── Loading spinner ──────────────────────────────────────────────────────────
describe('Home page loading spinner', () => {
it('does not show spinner by default', async () => {
render(Page, { data: emptyData });
const spinner = page.getByRole('status');
await expect.element(spinner).not.toBeInTheDocument();
});
});
// ─── Sort controls ────────────────────────────────────────────────────────────
describe('Home page sort controls', () => {
it('pre-fills sort from filters.sort', async () => {
const data = {
...emptyData,
filters: { ...emptyData.filters, sort: 'TITLE', dir: 'asc', tagQ: '' }
};
render(Page, { data });
const select = page.getByRole('combobox');
await expect.element(select).toHaveValue('TITLE');
});
it('renders direction toggle with asc indicator when dir is asc', async () => {
const data = {
...emptyData,
filters: { ...emptyData.filters, sort: 'DATE', dir: 'asc', tagQ: '' }
};
render(Page, { data });
const btn = page.getByRole('button', { name: /aufsteigend/i });
await expect.element(btn).toBeInTheDocument();
});
});