Compare commits

...

4 Commits

Author SHA1 Message Date
Marcel
b2b3eb0b1c feat(geschichten): integrate DocumentFilterChip into list page
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m1s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 4m30s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
Add DocumentFilterChip to the filter bar, extract emptyMessage as $derived.by() with person-wins precedence, and add removeDocument navigation helper. Update tests: add document-filter chip and empty-state-precedence suites, fix person-chip click test to use native element.click() + vi.waitFor() for reliable Svelte 5 onclick triggering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 09:27:20 +02:00
Marcel
4d8b8f15ad feat(geschichten): add DocumentFilterChip component with spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 09:12:37 +02:00
Marcel
ba2481aef5 feat(geschichten): resolve document title in loader, return documentFilter object
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 09:05:53 +02:00
Marcel
5a87e4d655 feat(geschichten): add i18n keys for document filter chip and empty state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 09:00:21 +02:00
11 changed files with 530 additions and 14 deletions

3
frontend/.gitignore vendored
View File

@@ -13,6 +13,9 @@ node_modules
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Leftover directory from branch work
/src.main/
# Env # Env
.env .env
.env.* .env.*

View File

@@ -7,6 +7,7 @@ bun.lockb
# Miscellaneous # Miscellaneous
/static/ /static/
/src.main/
# Build artifacts # Build artifacts
/.svelte-kit/ /.svelte-kit/

View File

@@ -1033,6 +1033,9 @@
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.", "geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
"geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.", "geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.",
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.", "geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
"geschichten_filter_document_chip": "Gefiltert nach Brief:",
"geschichten_filter_remove_document_chip": "Brief {title} aus Filter entfernen",
"geschichten_empty_for_document": "Noch keine Geschichten zu diesem Brief",
"geschichten_back_to_index": "Zurück zu Geschichten", "geschichten_back_to_index": "Zurück zu Geschichten",
"geschichten_published_on": "veröffentlicht am {date}", "geschichten_published_on": "veröffentlicht am {date}",
"geschichten_persons_section": "Personen in dieser Geschichte", "geschichten_persons_section": "Personen in dieser Geschichte",

View File

@@ -1033,6 +1033,9 @@
"geschichten_empty_for_person": "No stories found for {name}.", "geschichten_empty_for_person": "No stories found for {name}.",
"geschichten_empty_for_persons": "No stories found for {names}.", "geschichten_empty_for_persons": "No stories found for {names}.",
"geschichten_empty_no_filter": "There are no published stories yet.", "geschichten_empty_no_filter": "There are no published stories yet.",
"geschichten_filter_document_chip": "Filtered by letter:",
"geschichten_filter_remove_document_chip": "Remove letter {title} from filter",
"geschichten_empty_for_document": "No stories reference this letter yet",
"geschichten_back_to_index": "Back to stories", "geschichten_back_to_index": "Back to stories",
"geschichten_published_on": "published on {date}", "geschichten_published_on": "published on {date}",
"geschichten_persons_section": "People in this story", "geschichten_persons_section": "People in this story",

View File

@@ -1033,6 +1033,9 @@
"geschichten_empty_for_person": "No hay historias para {name}.", "geschichten_empty_for_person": "No hay historias para {name}.",
"geschichten_empty_for_persons": "No hay historias para {names}.", "geschichten_empty_for_persons": "No hay historias para {names}.",
"geschichten_empty_no_filter": "Aún no hay historias publicadas.", "geschichten_empty_no_filter": "Aún no hay historias publicadas.",
"geschichten_filter_document_chip": "Filtrado por carta:",
"geschichten_filter_remove_document_chip": "Quitar la carta {title} del filtro",
"geschichten_empty_for_document": "Aún no hay historias sobre esta carta",
"geschichten_back_to_index": "Volver a Historias", "geschichten_back_to_index": "Volver a Historias",
"geschichten_published_on": "publicada el {date}", "geschichten_published_on": "publicada el {date}",
"geschichten_persons_section": "Personas en esta historia", "geschichten_persons_section": "Personas en esta historia",

View File

@@ -6,21 +6,28 @@ import type { PageServerLoad } from './$types';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
const isUuid = (s: string) =>
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(s);
export const load: PageServerLoad = async ({ url, fetch }) => { export const load: PageServerLoad = async ({ url, fetch }) => {
const api = createApiClient(fetch); const api = createApiClient(fetch);
const personIds = url.searchParams.getAll('personId'); const personIds = url.searchParams.getAll('personId');
const documentId = url.searchParams.get('documentId') ?? undefined; const rawDocumentId = url.searchParams.get('documentId');
const documentId = rawDocumentId && isUuid(rawDocumentId) ? rawDocumentId : null;
const [listResult, ...personResults] = await Promise.all([ const [listResult, docResult, ...personResults] = await Promise.all([
api.GET('/api/geschichten', { api.GET('/api/geschichten', {
params: { params: {
query: { query: {
status: 'PUBLISHED', status: 'PUBLISHED',
personId: personIds.length ? personIds : undefined, personId: personIds.length ? personIds : undefined,
documentId documentId: rawDocumentId ?? undefined
} }
} }
}), }),
documentId
? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } })
: Promise.resolve(null),
...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } })) ...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } }))
]); ]);
@@ -32,9 +39,22 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
.filter((r) => r && r.response.ok && r.data) .filter((r) => r && r.response.ok && r.data)
.map((r) => r!.data!) as Person[]; .map((r) => r!.data!) as Person[];
let documentFilter: { id: string; title: string | null } | null = null;
if (documentId) {
if (docResult && docResult.response.ok && docResult.data) {
const doc = docResult.data;
documentFilter = {
id: documentId,
title: doc.title || doc.originalFilename || null
};
} else {
documentFilter = { id: documentId, title: null };
}
}
return { return {
geschichten: listResult.data ?? [], geschichten: listResult.data ?? [],
personFilters, personFilters,
documentFilter: documentId ?? null documentFilter
}; };
}; };

View File

@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/shared/utils/extractText'; import { plainExcerpt } from '$lib/shared/utils/extractText';
import { formatDate } from '$lib/shared/utils/date'; import { formatDate } from '$lib/shared/utils/date';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import DocumentFilterChip from './DocumentFilterChip.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -11,7 +12,19 @@ let { data }: { data: PageData } = $props();
let showPersonPicker = $state(false); let showPersonPicker = $state(false);
const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!)); const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!));
const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter); const hasFilters = $derived(data.personFilters.length > 0 || data.documentFilter !== null);
const emptyMessage = $derived.by(() => {
if (data.personFilters.length > 0) {
return m.geschichten_empty_for_persons({
names: data.personFilters.map((p) => p.displayName).join(' & ')
});
}
if (data.documentFilter) {
return m.geschichten_empty_for_document();
}
return m.geschichten_empty_no_filter();
});
function rebuildUrl(personIds: string[]) { function rebuildUrl(personIds: string[]) {
const url = new URL(window.location.href); const url = new URL(window.location.href);
@@ -38,6 +51,10 @@ function removePerson(personId: string) {
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId))); goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
} }
function removeDocument() {
goto(rebuildUrl(selectedPersonIds));
}
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) { function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
const a = g.author; const a = g.author;
if (!a) return ''; if (!a) return '';
@@ -88,6 +105,14 @@ function publishedAt(g: { publishedAt?: string }): string | null {
</button> </button>
{/each} {/each}
{#if data.documentFilter}
<DocumentFilterChip
id={data.documentFilter.id}
title={data.documentFilter.title}
onremove={removeDocument}
/>
{/if}
<button <button
type="button" type="button"
aria-expanded={showPersonPicker} aria-expanded={showPersonPicker}
@@ -118,13 +143,7 @@ function publishedAt(g: { publishedAt?: string }): string | null {
<!-- Card list --> <!-- Card list -->
{#if data.geschichten.length === 0} {#if data.geschichten.length === 0}
<div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3"> <div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3">
{#if data.personFilters.length > 0} {emptyMessage}
{m.geschichten_empty_for_persons({
names: data.personFilters.map((p) => p.displayName).join(' & ')
})}
{:else}
{m.geschichten_empty_no_filter()}
{/if}
</div> </div>
{:else} {:else}
<ul class="flex flex-col gap-4"> <ul class="flex flex-col gap-4">

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
id,
title,
onremove
}: {
id: string;
title: string | null;
onremove: () => void;
} = $props();
const chipLabel = $derived(title ?? id.slice(0, 8));
</script>
<div
class="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-primary bg-primary px-3 text-primary-fg"
aria-live="polite"
>
<span class="font-sans text-xs tracking-wider whitespace-nowrap uppercase">
{m.geschichten_filter_document_chip()}
</span>
<span class="line-clamp-2 font-serif italic sm:max-w-[16rem] sm:truncate" title={chipLabel}>
{chipLabel}
</span>
<button
type="button"
onclick={onremove}
aria-label={m.geschichten_filter_remove_document_chip({ title: chipLabel })}
class="ml-0.5 flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
>
<span aria-hidden="true">×</span>
</button>
</div>

View File

@@ -0,0 +1,87 @@
import { afterEach, 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() }));
import DocumentFilterChip from './DocumentFilterChip.svelte';
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const VALID_UUID = '11111111-2222-3333-4444-555555555555';
describe('DocumentFilterChip', () => {
it('renders the resolved document title inside the chip', async () => {
render(DocumentFilterChip, {
props: {
id: VALID_UUID,
title: 'Brief an Oma',
onremove: vi.fn()
}
});
await expect.element(page.getByText(/Brief an Oma/)).toBeVisible();
});
it('renders the prefix label', async () => {
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
});
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
});
it('falls back to short UUID when title is null', async () => {
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: null, onremove: vi.fn() }
});
await expect.element(page.getByText(/11111111/)).toBeVisible();
});
it('fires onremove when the remove button is clicked', async () => {
const onremove = vi.fn();
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: 'Brief an Oma', onremove }
});
const btn = (await page
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
.element()) as HTMLElement;
btn.click();
await vi.waitFor(() => expect(onremove).toHaveBeenCalledOnce());
});
it('remove button aria-label references the resolved title', async () => {
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
});
const btn = page.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ });
await expect.element(btn).toBeVisible();
});
it('title= attribute equals the validated id, not a raw query string', async () => {
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
});
const chip = document.querySelector('[title]');
expect(chip?.getAttribute('title')).toBe('Brief an Oma');
});
it('remove button has a minimum 44px touch target', async () => {
render(DocumentFilterChip, {
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
});
const btn = (await page
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
.element()) as HTMLElement;
expect(btn.className).toMatch(/min-h-\[44px\]|min-h-11/);
});
});

View File

@@ -0,0 +1,185 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { load } from './+page.server';
import { createApiClient } from '$lib/shared/api.server';
beforeEach(() => vi.clearAllMocks());
const VALID_UUID = '11111111-2222-3333-4444-555555555555';
function makeUrl(params: Record<string, string | string[]> = {}) {
const url = new URL('http://localhost/geschichten');
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
value.forEach((v) => url.searchParams.append(key, v));
} else {
url.searchParams.set(key, value);
}
}
return url;
}
function callLoad(url: URL) {
return load({
url,
request: new Request('http://localhost/geschichten'),
fetch: vi.fn() as unknown as typeof fetch
});
}
function mockApi(
opts: {
listData?: unknown[];
docOk?: boolean;
docData?: Record<string, unknown> | null;
} = {}
) {
const {
listData = [],
docOk = true,
docData = { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' }
} = opts;
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/api/documents/{id}') {
return Promise.resolve({
response: { ok: docOk, status: docOk ? 200 : 404 },
data: docOk ? docData : undefined
});
}
return Promise.resolve({
response: { ok: true, status: 200 },
data: listData
});
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
return mockGet;
}
describe('geschichten page load — documentFilter title resolution', () => {
it('resolves document title when documentId is a valid UUID and document exists', async () => {
mockApi({ docData: { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' } });
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'Brief an Oma' });
});
it('falls back to originalFilename when document title is null', async () => {
mockApi({ docData: { id: VALID_UUID, title: null, originalFilename: 'scan_001.jpg' } });
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'scan_001.jpg' });
});
it('degrades to {id, title: null} on 404 without throwing (resolves, never rejects)', async () => {
// Explicit .resolves locks the no-throw guarantee — if error() were called, this would reject
mockApi({ docOk: false });
await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({
documentFilter: { id: VALID_UUID, title: null }
});
});
it('treats 403 identically to 404 — no oracle, loader still resolves', async () => {
// Permanent regression test: loader must not call getErrorMessage/throw on a forbidden title fetch.
// If it did, this assertion would fail with a rejection instead of a resolution.
const mockGet = vi.fn().mockImplementation((path: string) => {
if (path === '/api/documents/{id}') {
return Promise.resolve({ response: { ok: false, status: 403 }, data: undefined });
}
return Promise.resolve({ response: { ok: true, status: 200 }, data: [] });
});
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({
documentFilter: { id: VALID_UUID, title: null }
});
});
it('list still populates when title fetch returns 404 (independent results)', async () => {
mockApi({
listData: [{ id: 'g1', title: 'Some Story' }],
docOk: false
});
const result = await callLoad(makeUrl({ documentId: VALID_UUID }));
expect(result.geschichten).toHaveLength(1);
expect(result.documentFilter).toEqual({ id: VALID_UUID, title: null });
});
it('returns null documentFilter when documentId is syntactically invalid', async () => {
mockApi();
const result = await callLoad(makeUrl({ documentId: 'not-a-uuid' }));
expect(result.documentFilter).toBeNull();
});
it('does not fetch document title when documentId is invalid', async () => {
const mockGet = mockApi();
await callLoad(makeUrl({ documentId: 'not-a-uuid' }));
expect(mockGet).not.toHaveBeenCalledWith('/api/documents/{id}', expect.anything());
});
it('returns null documentFilter when documentId is absent', async () => {
mockApi();
const result = await callLoad(makeUrl());
expect(result.documentFilter).toBeNull();
});
it('passes valid documentId to the geschichten API', async () => {
const mockGet = mockApi();
await callLoad(makeUrl({ documentId: VALID_UUID }));
expect(mockGet).toHaveBeenCalledWith(
'/api/geschichten',
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({ documentId: VALID_UUID })
})
})
);
});
it('passes invalid documentId to the list API without stripping (option B)', async () => {
const mockGet = mockApi();
await callLoad(makeUrl({ documentId: 'not-a-uuid' }));
const listCall = mockGet.mock.calls.find((c) => c[0] === '/api/geschichten');
expect(listCall?.[1]?.params?.query?.documentId).toBe('not-a-uuid');
});
it('keeps forwarding personId filters alongside documentId', async () => {
const mockGet = mockApi();
await callLoad(makeUrl({ documentId: VALID_UUID, personId: [VALID_UUID] }));
expect(mockGet).toHaveBeenCalledWith(
'/api/geschichten',
expect.objectContaining({
params: expect.objectContaining({
query: expect.objectContaining({ documentId: VALID_UUID, personId: [VALID_UUID] })
})
})
);
});
});

View File

@@ -33,6 +33,17 @@ function makeData(overrides: Partial<PageData> = {}): PageData {
} as unknown as PageData; } as unknown as PageData;
} }
function makeDocumentFilter(overrides: { id?: string; title?: string | null } = {}): {
id: string;
title: string | null;
} {
return {
id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
title: 'Brief an Oma',
...overrides
};
}
describe('geschichten page — multi-person filter chips', () => { describe('geschichten page — multi-person filter chips', () => {
it('renders one chip per person in personFilters', async () => { it('renders one chip per person in personFilters', async () => {
render(Page, { render(Page, {
@@ -81,9 +92,12 @@ describe('geschichten page — multi-person filter chips', () => {
}) })
}); });
await page.getByRole('button', { name: /Anna A aus Filter entfernen/ }).click(); const chipBtn = (await page
.getByRole('button', { name: /Anna A aus Filter entfernen/ })
.element()) as HTMLElement;
chipBtn.click();
expect(goto).toHaveBeenCalledOnce(); await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
const url = vi.mocked(goto).mock.calls[0][0] as string; const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).toContain('personId=b'); expect(url).toContain('personId=b');
expect(url).not.toContain('personId=a'); expect(url).not.toContain('personId=a');
@@ -100,6 +114,149 @@ describe('geschichten page — multi-person filter chips', () => {
await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible(); await expect.element(page.getByRole('button', { name: /Person wählen/ })).toBeVisible();
}); });
describe('document filter chip', () => {
it('renders the document chip when documentFilter is set', async () => {
render(Page, {
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
});
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
await expect.element(page.getByText(/Brief an Oma/)).toBeVisible();
});
it('does not render the document chip when documentFilter is null', async () => {
render(Page, { data: makeData() });
await expect.element(page.getByText(/Gefiltert nach Brief/)).not.toBeInTheDocument();
});
it('clicking the document chip remove button navigates without documentId', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
window.history.replaceState(
{},
'',
'/geschichten?documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
);
render(Page, {
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
});
const removeBtn = (await page
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
.element()) as HTMLElement;
removeBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).not.toContain('documentId');
});
it('document chip removal preserves active person filters', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
window.history.replaceState(
{},
'',
'/geschichten?personId=p1&documentId=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
);
render(Page, {
data: makeData({
personFilters: [person('p1', 'Anna A')] as PageData['personFilters'],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
const removeBtn = (await page
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
.element()) as HTMLElement;
removeBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledOnce());
const url = vi.mocked(goto).mock.calls[0][0] as string;
expect(url).toContain('personId=p1');
expect(url).not.toContain('documentId');
});
it('marks the "All" pill as unpressed when document filter is active', async () => {
render(Page, {
data: makeData({ documentFilter: makeDocumentFilter() as PageData['documentFilter'] })
});
await expect
.element(page.getByRole('button', { name: 'Alle' }))
.toHaveAttribute('aria-pressed', 'false');
});
});
describe('empty state precedence', () => {
it('shows geschichten_empty_for_document when only document filter is active', async () => {
render(Page, {
data: makeData({
geschichten: [],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
await expect.element(page.getByText('Noch keine Geschichten zu diesem Brief')).toBeVisible();
});
it('shows geschichten_empty_for_persons when only person filter is active', async () => {
render(Page, {
data: makeData({
geschichten: [],
personFilters: [person('a', 'Anna A')] as PageData['personFilters']
})
});
await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible();
});
it('shows geschichten_empty_no_filter when no filter is active', async () => {
render(Page, { data: makeData({ geschichten: [] }) });
await expect
.element(page.getByText('Es gibt noch keine veröffentlichten Geschichten.'))
.toBeVisible();
});
it('person-wins: shows persons message when both person and document filters are active', async () => {
render(Page, {
data: makeData({
geschichten: [],
personFilters: [person('a', 'Anna A')] as PageData['personFilters'],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
await expect.element(page.getByText(/Keine Geschichten für Anna A gefunden/)).toBeVisible();
await expect
.element(page.getByText('Noch keine Geschichten zu diesem Brief'))
.not.toBeInTheDocument();
});
it('chip renders alongside results (empty state not shown when results exist)', async () => {
render(Page, {
data: makeData({
geschichten: [
{ id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' }
] as PageData['geschichten'],
documentFilter: makeDocumentFilter() as PageData['documentFilter']
})
});
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
await expect.element(page.getByText(/Lesereise Berlin/)).toBeVisible();
await expect
.element(page.getByText('Noch keine Geschichten zu diesem Brief'))
.not.toBeInTheDocument();
});
});
it('renders all filter pills with a 44px touch target (h-11)', async () => { it('renders all filter pills with a 44px touch target (h-11)', async () => {
render(Page, { render(Page, {
data: makeData({ data: makeData({