diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index dce0853b..bf930769 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -1,51 +1,39 @@ @@ -71,205 +59,35 @@ const showDividers = $derived(groupedDocuments.length >= 2);

{m.docs_result_count({ count: total })}

{/if} - -
- {#if error} + +{#if error} +
{error}
- {:else if documents.length > 0} - {#each groupedDocuments as group (group.label)} - {#if showDividers} - - {/if} -
+{:else if items.length > 0} + + {#each yearGroups as group (group.year)} +
+
+ {group.year} +
+ - {/each} - {:else} - +
+ {/each} +{:else} + +
= 2); {q ? m.docs_empty_for_term({ term: q }) : m.docs_empty_text()}

- {/if} -
+
+{/if} diff --git a/frontend/src/routes/DocumentList.svelte.spec.ts b/frontend/src/routes/DocumentList.svelte.spec.ts index bb6e61da..f90882fa 100644 --- a/frontend/src/routes/DocumentList.svelte.spec.ts +++ b/frontend/src/routes/DocumentList.svelte.spec.ts @@ -2,64 +2,63 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import DocumentList from './DocumentList.svelte'; +import type { components } from '$lib/generated/api'; vi.mock('$app/navigation', () => ({ goto: vi.fn() })); afterEach(() => cleanup()); -const baseProps = { - documents: [], - canWrite: false, - error: null, - total: 0, - q: '', - matchData: {} as Record< - string, - import('$lib/generated/api').components['schemas']['SearchMatchData'] - > -}; +type DocumentSearchItem = components['schemas']['DocumentSearchItem']; -type DocOverrides = { - id?: string; - title?: string; - documentDate?: string | null; - sender?: { id?: string; firstName?: string | null; lastName: string; displayName: string } | null; - receivers?: { id?: string; firstName?: string | null; lastName: string; displayName: string }[]; - tags?: { id: string; name: string }[]; -}; +function makeItem(overrides: Partial = {}): DocumentSearchItem { + return { + document: { + id: '1', + title: 'Testbrief', + originalFilename: 'testbrief.pdf', + status: 'UPLOADED', + documentDate: '2024-03-15', + sender: null, + receivers: [], + tags: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + metadataComplete: false, + scriptType: 'UNKNOWN' + }, + matchData: { + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + }, + completionPercentage: 0, + contributors: [], + ...overrides + }; +} -const makeDoc = (overrides: DocOverrides = {}) => ({ - id: '1', - title: 'Testbrief', - originalFilename: 'testbrief.pdf', - status: 'UPLOADED' as const, - documentDate: '2024-03-15', - location: null, - sender: null, - receivers: [] as { - id?: string; - firstName?: string | null; - lastName: string; - displayName: string; - }[], - tags: [], - ...overrides -}); +const baseProps = { items: [], canWrite: false, error: null, total: 0, q: '' }; + +// ─── Result count ───────────────────────────────────────────────────────────── describe('DocumentList – result count', () => { it('shows result count when total > 0', async () => { - render(DocumentList, { ...baseProps, documents: [makeDoc()], total: 1, q: 'test' }); + render(DocumentList, { ...baseProps, items: [makeItem()], total: 1, q: 'test' }); await expect.element(page.getByText('1 Dokumente')).toBeInTheDocument(); }); - it('does not show result count when total is 0 and there is no error', async () => { + it('does not show result count when total is 0', async () => { render(DocumentList, { ...baseProps, total: 0, q: '' }); - const count = page.getByText(/\d+ Dokumente/); - await expect.element(count).not.toBeInTheDocument(); + await expect.element(page.getByText(/\d+ Dokumente/)).not.toBeInTheDocument(); }); }); -describe('DocumentList – empty state with search term', () => { +// ─── Empty state ────────────────────────────────────────────────────────────── + +describe('DocumentList – empty state', () => { it('shows generic empty heading when q is empty', async () => { render(DocumentList, { ...baseProps }); await expect.element(page.getByText(/Keine Dokumente/)).toBeInTheDocument(); @@ -71,73 +70,49 @@ describe('DocumentList – empty state with search term', () => { }); }); -// ─── Group headers ──────────────────────────────────────────────────────────── +// ─── Year grouping ──────────────────────────────────────────────────────────── -describe('DocumentList – group headers', () => { - it('renders group-divider elements when DATE sort spans multiple years', async () => { - const documents = [ - makeDoc({ id: '1', documentDate: '1923-04-12' }), - makeDoc({ id: '2', documentDate: '1965-08-03' }) +describe('DocumentList – year grouping', () => { + it('groups documents by year into separate cards', async () => { + const items = [ + makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1923-04-12' } }), + makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1965-08-03' } }) ]; - render(DocumentList, { ...baseProps, documents, total: 2, sort: 'DATE' }); - await expect.element(page.getByTestId('group-divider').first()).toBeInTheDocument(); + render(DocumentList, { ...baseProps, items, total: 2 }); + const yearCards = page.getByTestId('year-card'); + await expect.element(yearCards.first()).toBeInTheDocument(); + await expect.element(yearCards.nth(1)).toBeInTheDocument(); }); - it('does not render group-divider when DATE sort has only one distinct year', async () => { - const documents = [ - makeDoc({ id: '1', documentDate: '1938-01-01' }), - makeDoc({ id: '2', documentDate: '1938-06-15' }) + it('uses Ohne Datum for items with no documentDate', async () => { + const items = [ + makeItem({ document: { ...makeItem().document, id: '1', documentDate: undefined } }) ]; - render(DocumentList, { ...baseProps, documents, total: 2, sort: 'DATE' }); - await expect.element(page.getByTestId('group-divider')).not.toBeInTheDocument(); + render(DocumentList, { ...baseProps, items, total: 1 }); + await expect.element(page.getByText('Ohne Datum')).toBeInTheDocument(); }); - it('does not render group-divider for TITLE sort', async () => { - const documents = [ - makeDoc({ id: '1', documentDate: '1923-04-12' }), - makeDoc({ id: '2', documentDate: '1965-08-03' }) + it('single year renders one year-card', async () => { + const items = [ + makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1938-01-01' } }), + makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1938-06-15' } }) ]; - render(DocumentList, { ...baseProps, documents, total: 2, sort: 'TITLE' }); - await expect.element(page.getByTestId('group-divider')).not.toBeInTheDocument(); - }); - - it('shows Undatiert fallback label when sort is undefined and doc has no date', async () => { - const documents = [ - makeDoc({ id: '1', documentDate: '1938-01-01' }), - makeDoc({ id: '2', documentDate: null }) - ]; - render(DocumentList, { ...baseProps, documents, total: 2 }); // sort omitted — defaults to DATE grouping - await expect.element(page.getByText(/UNDATIERT/i)).toBeInTheDocument(); - }); - - it('a doc with two receivers appears in both receiver groups', async () => { - const documents = [ - makeDoc({ - id: '1', - receivers: [ - { firstName: null, lastName: 'Müller', displayName: 'Anna Müller' }, - { firstName: null, lastName: 'Bauer', displayName: 'Karl Bauer' } - ] - }) - ]; - render(DocumentList, { ...baseProps, documents, total: 1, sort: 'RECEIVER' }); - const links = page.getByRole('link', { name: /Testbrief/ }); - await expect.element(links.first()).toBeInTheDocument(); - await expect.element(links.nth(1)).toBeInTheDocument(); + render(DocumentList, { ...baseProps, items, total: 2 }); + const yearCards = page.getByTestId('year-card'); + // Only one card for 1938 + await expect.element(yearCards.first()).toBeInTheDocument(); + await expect.element(yearCards.nth(1)).not.toBeInTheDocument(); }); }); -// ─── Match data: snippet and title highlighting ─────────────────────────────── +// ─── DocumentRow rendering (delegated) ─────────────────────────────────────── -describe('DocumentList – match snippets and highlights', () => { - it('shows transcription snippet when matchData has one for the document', async () => { - const doc = makeDoc({ id: 'doc1' }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { +describe('DocumentList – DocumentRow delegation', () => { + it('shows transcription snippet when matchData has one', async () => { + const items = [ + makeItem({ + document: { ...makeItem().document, id: 'doc1' }, + matchData: { transcriptionSnippet: 'Er schrieb einen langen Brief', titleOffsets: [], senderMatched: false, @@ -146,26 +121,23 @@ describe('DocumentList – match snippets and highlights', () => { snippetOffsets: [], summaryOffsets: [] } - } - }); + }) + ]; + render(DocumentList, { ...baseProps, items, total: 1 }); await expect.element(page.getByText('Er schrieb einen langen Brief')).toBeInTheDocument(); }); - it('does not show snippet section when matchData has no entry for the document', async () => { - const doc = makeDoc({ id: 'doc1' }); - render(DocumentList, { ...baseProps, documents: [doc], total: 1, matchData: {} }); + it('does not render snippet when matchData has no transcription snippet', async () => { + const items = [makeItem({ document: { ...makeItem().document, id: 'doc1' } })]; + render(DocumentList, { ...baseProps, items, total: 1 }); await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument(); }); - it('renders a element when titleOffsets are present', async () => { - const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, + it('renders mark for title highlight when titleOffsets present', async () => { + const items = [ + makeItem({ + document: { ...makeItem().document, id: 'doc1', title: 'Brief an Anna' }, + matchData: { titleOffsets: [{ start: 0, length: 5 }], // "Brief" senderMatched: false, matchedReceiverIds: [], @@ -173,221 +145,11 @@ describe('DocumentList – match snippets and highlights', () => { snippetOffsets: [], summaryOffsets: [] } - } - }); - // The word "Brief" should be inside a element + }) + ]; + render(DocumentList, { ...baseProps, items, total: 1 }); const mark = page.getByRole('mark'); await expect.element(mark).toBeInTheDocument(); await expect.element(mark).toHaveTextContent('Brief'); }); - - it('renders title as plain text when titleOffsets is empty', async () => { - const doc = makeDoc({ id: 'doc1', title: 'Brief an Anna' }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: [], - matchedTagIds: [], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - await expect.element(page.getByRole('mark')).not.toBeInTheDocument(); - await expect.element(page.getByText('Brief an Anna')).toBeInTheDocument(); - }); - - it('renders inside snippet when snippetOffsets are present', async () => { - const doc = makeDoc({ id: 'doc1' }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: 'Er schrieb einen Brief', - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: [], - matchedTagIds: [], - snippetOffsets: [{ start: 17, length: 5 }], // "Brief" - summaryOffsets: [] - } - } - }); - const snippet = page.getByTestId('search-snippet'); - await expect.element(snippet).toBeInTheDocument(); - const mark = snippet.getByRole('mark'); - await expect.element(mark).toBeInTheDocument(); - await expect.element(mark).toHaveTextContent('Brief'); - }); - - it('renders snippet as plain text when snippetOffsets is empty', async () => { - const doc = makeDoc({ id: 'doc1' }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: 'Er schrieb einen Brief', - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: [], - matchedTagIds: [], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - const snippet = page.getByTestId('search-snippet'); - await expect.element(snippet).toBeInTheDocument(); - // No mark elements inside the snippet when offsets is empty - await expect.element(snippet.getByRole('mark')).not.toBeInTheDocument(); - }); - - it('visually marks sender when senderMatched is true', async () => { - const doc = makeDoc({ - id: 'doc1', - sender: { - id: 'sender-1', - firstName: 'Walter', - lastName: 'Raddatz', - displayName: 'Walter Raddatz' - } - }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, - titleOffsets: [], - senderMatched: true, - matchedReceiverIds: [], - matchedTagIds: [], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - const senderMark = page.getByTestId('sender-match'); - await expect.element(senderMark).toBeInTheDocument(); - await expect.element(senderMark).toHaveTextContent('Walter Raddatz'); - }); - - it('does not mark sender when senderMatched is false', async () => { - const doc = makeDoc({ - id: 'doc1', - sender: { - id: 'sender-1', - firstName: 'Walter', - lastName: 'Raddatz', - displayName: 'Walter Raddatz' - } - }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: [], - matchedTagIds: [], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - await expect.element(page.getByTestId('sender-match')).not.toBeInTheDocument(); - }); - - it('visually marks matched receiver when their id is in matchedReceiverIds', async () => { - const doc = makeDoc({ - id: 'doc1', - receivers: [ - { id: 'p-1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }, - { id: 'p-2', firstName: 'Karl', lastName: 'Bauer', displayName: 'Karl Bauer' } - ] - }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: ['p-1'], - matchedTagIds: [], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - // Only Anna Schmidt should be marked - const receiverMark = page.getByTestId('receiver-match'); - await expect.element(receiverMark).toBeInTheDocument(); - await expect.element(receiverMark).toHaveTextContent('Anna Schmidt'); - }); - - it('renders a color dot on tag chips that have a color', async () => { - const doc = makeDoc({ - id: 'doc1', - tags: [{ id: 'tag-1', name: 'Familie', color: 'sage' }] - }); - render(DocumentList, { ...baseProps, documents: [doc], total: 1 }); - const dot = page.getByTestId('tag-color-dot'); - await expect.element(dot).toBeInTheDocument(); - await expect.element(dot).toHaveAttribute('data-color', 'sage'); - }); - - it('does not render a color dot on tag chips without a color', async () => { - const doc = makeDoc({ - id: 'doc1', - tags: [{ id: 'tag-1', name: 'Familie' }] - }); - render(DocumentList, { ...baseProps, documents: [doc], total: 1 }); - await expect.element(page.getByTestId('tag-color-dot')).not.toBeInTheDocument(); - }); - - it('visually marks matched tag when its id is in matchedTagIds', async () => { - const doc = makeDoc({ - id: 'doc1', - tags: [ - { id: 'tag-1', name: 'Familiengeschichte' }, - { id: 'tag-2', name: 'Reise' } - ] - }); - render(DocumentList, { - ...baseProps, - documents: [doc], - total: 1, - matchData: { - doc1: { - transcriptionSnippet: undefined, - titleOffsets: [], - senderMatched: false, - matchedReceiverIds: [], - matchedTagIds: ['tag-1'], - snippetOffsets: [], - summaryOffsets: [] - } - } - }); - const tagMark = page.getByTestId('tag-match'); - await expect.element(tagMark).toBeInTheDocument(); - await expect.element(tagMark).toHaveTextContent('Familiengeschichte'); - }); });