diff --git a/frontend/src/lib/components/ContributorStack.svelte b/frontend/src/lib/components/ContributorStack.svelte
index 4e417fc2..3204a7dc 100644
--- a/frontend/src/lib/components/ContributorStack.svelte
+++ b/frontend/src/lib/components/ContributorStack.svelte
@@ -11,21 +11,26 @@ interface Props {
let { contributors, hasMore }: Props = $props();
const safeContributors = $derived(contributors ?? []);
+
+function safeColor(color: string): string {
+ return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#8c9aa3';
+}
{#if safeContributors.length === 0}
{:else}
- {#each safeContributors as actor, i (actor.initials + '-' + actor.color)}
+ {#each safeContributors as actor, i (i)}
{actor.initials}
@@ -33,7 +38,7 @@ const safeContributors = $derived(contributors ?? []);
{/each}
{#if hasMore}
…
diff --git a/frontend/src/lib/components/ContributorStack.svelte.spec.ts b/frontend/src/lib/components/ContributorStack.svelte.spec.ts
index 877167cd..4bb847c9 100644
--- a/frontend/src/lib/components/ContributorStack.svelte.spec.ts
+++ b/frontend/src/lib/components/ContributorStack.svelte.spec.ts
@@ -45,6 +45,8 @@ describe('ContributorStack', () => {
it('renders empty placeholder when no contributors', async () => {
render(ContributorStack, { contributors: [], hasMore: false });
- await expect.element(page.getByTitle('Noch niemand angefangen')).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('img', { name: 'Noch niemand angefangen' }))
+ .toBeInTheDocument();
});
});
diff --git a/frontend/src/lib/components/DocumentRow.svelte.spec.ts b/frontend/src/lib/components/DocumentRow.svelte.spec.ts
index 0f85fb94..a5b335e4 100644
--- a/frontend/src/lib/components/DocumentRow.svelte.spec.ts
+++ b/frontend/src/lib/components/DocumentRow.svelte.spec.ts
@@ -145,6 +145,19 @@ describe('DocumentRow – tags', () => {
await page.getByRole('button', { name: 'Urlaub & Reise' }).click();
expect(goto).toHaveBeenCalledWith('/documents?tag=Urlaub%20%26%20Reise');
});
+
+ it('tag click does not navigate to the document detail page', async () => {
+ const item = makeItem({
+ document: {
+ ...makeItem().document,
+ tags: [{ id: 't2', name: 'Familie', color: null, parentId: null }]
+ }
+ });
+ render(DocumentRow, { item });
+ const before = window.location.href;
+ await page.getByRole('button', { name: 'Familie' }).click();
+ expect(window.location.href).toBe(before);
+ });
});
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
diff --git a/frontend/src/routes/documents/page.svelte.spec.ts b/frontend/src/routes/documents/page.svelte.spec.ts
new file mode 100644
index 00000000..59af31bf
--- /dev/null
+++ b/frontend/src/routes/documents/page.svelte.spec.ts
@@ -0,0 +1,121 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+
+vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
+vi.mock('$app/state', () => ({ navigating: { to: null } }));
+
+import Page from './+page.svelte';
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+const SEARCH_LABEL = 'Titel, Personen, Tags durchsuchen…';
+
+function makeData(overrides: Record = {}) {
+ return {
+ items: [],
+ total: 0,
+ q: '',
+ from: '',
+ to: '',
+ senderId: '',
+ receiverId: '',
+ tags: [],
+ sort: 'DATE',
+ dir: 'desc',
+ tagQ: '',
+ tagOp: 'AND',
+ canWrite: false,
+ error: null,
+ ...overrides
+ };
+}
+
+// ─── Initial state from server data ───────────────────────────────────────────
+
+describe('documents page — initial state', () => {
+ it('pre-fills the search input from data.q', async () => {
+ render(Page, { data: makeData({ q: 'Geburtstag' }) });
+ await expect
+ .element(page.getByRole('textbox', { name: SEARCH_LABEL }))
+ .toHaveValue('Geburtstag');
+ });
+
+ it('leaves the search input empty when data.q is not set', async () => {
+ render(Page, { data: makeData() });
+ await expect.element(page.getByRole('textbox', { name: SEARCH_LABEL })).toHaveValue('');
+ });
+});
+
+// ─── URL building via triggerSearch ───────────────────────────────────────────
+
+describe('documents page — URL building', () => {
+ beforeEach(() => vi.useFakeTimers());
+
+ it('calls goto with /documents?q=… after the 500 ms debounce', async () => {
+ const { goto } = await import('$app/navigation');
+ vi.mocked(goto).mockClear();
+
+ render(Page, { data: makeData() });
+
+ const input = page.getByRole('textbox', { name: SEARCH_LABEL });
+ await input.fill('Urlaub');
+
+ expect(goto).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(500);
+
+ expect(goto).toHaveBeenCalledOnce();
+ const [url] = vi.mocked(goto).mock.calls[0];
+ expect(url).toContain('q=Urlaub');
+ expect(url).toMatch(/^\/documents\?/);
+ });
+
+ it('omits q from the URL when the search field is empty', async () => {
+ const { goto } = await import('$app/navigation');
+ vi.mocked(goto).mockClear();
+
+ render(Page, { data: makeData() });
+
+ const input = page.getByRole('textbox', { name: SEARCH_LABEL });
+ await input.fill('');
+
+ vi.advanceTimersByTime(500);
+
+ const [url] = vi.mocked(goto).mock.calls[0] ?? [''];
+ expect(url).not.toContain('q=');
+ });
+
+ it('second keystroke within 500 ms cancels the first timer — goto called only once', async () => {
+ const { goto } = await import('$app/navigation');
+ vi.mocked(goto).mockClear();
+
+ render(Page, { data: makeData() });
+
+ const input = page.getByRole('textbox', { name: SEARCH_LABEL });
+ await input.fill('U');
+ vi.advanceTimersByTime(200);
+ await input.fill('Urlaub');
+ vi.advanceTimersByTime(500);
+
+ expect(goto).toHaveBeenCalledOnce();
+ });
+
+ it('passes keepFocus and noScroll options to goto', async () => {
+ const { goto } = await import('$app/navigation');
+ vi.mocked(goto).mockClear();
+
+ render(Page, { data: makeData() });
+ const input = page.getByRole('textbox', { name: SEARCH_LABEL });
+ await input.fill('Brief');
+ vi.advanceTimersByTime(500);
+
+ expect(goto).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ keepFocus: true, noScroll: true })
+ );
+ });
+});