import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import { goto } from '$app/navigation'; import DocumentRow from './DocumentRow.svelte'; import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte'; import type { components } from '$lib/generated/api'; vi.mock('$app/navigation', () => ({ goto: vi.fn() })); afterEach(() => { cleanup(); vi.mocked(goto).mockClear(); bulkSelectionStore.clear(); }); type DocumentSearchItem = components['schemas']['DocumentSearchItem']; 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 }; } // ─── Title ──────────────────────────────────────────────────────────────────── describe('DocumentRow – title', () => { it('renders document title', async () => { render(DocumentRow, { item: makeItem() }); await expect.element(page.getByRole('heading', { name: 'Testbrief' })).toBeInTheDocument(); }); it('falls back to originalFilename when title is null', async () => { const item = makeItem({ document: { ...makeItem().document, title: null } }); render(DocumentRow, { item }); await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument(); }); it('renders a mark element for highlighted title offsets', async () => { const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' }, matchData: { titleOffsets: [{ start: 0, length: 5 }], senderMatched: false, matchedReceiverIds: [], matchedTagIds: [], snippetOffsets: [], summaryOffsets: [] } }); render(DocumentRow, { item }); const mark = page.getByRole('mark'); await expect.element(mark).toBeInTheDocument(); await expect.element(mark).toHaveTextContent('Brief'); }); }); // ─── Snippet ────────────────────────────────────────────────────────────────── describe('DocumentRow – snippet', () => { it('shows transcription snippet when present', async () => { const item = makeItem({ matchData: { transcriptionSnippet: 'Er schrieb einen langen Brief', titleOffsets: [], senderMatched: false, matchedReceiverIds: [], matchedTagIds: [], snippetOffsets: [], summaryOffsets: [] } }); render(DocumentRow, { item }); await expect.element(page.getByText('Er schrieb einen langen Brief')).toBeInTheDocument(); }); it('does not render snippet section when no snippet', async () => { render(DocumentRow, { item: makeItem() }); await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument(); }); }); // ─── Sender / receivers ─────────────────────────────────────────────────────── describe('DocumentRow – sender', () => { it('shows sender display name', async () => { const item = makeItem({ document: { ...makeItem().document, sender: { id: 's1', displayName: 'Großmutter Maria' } } }); render(DocumentRow, { item }); await expect.element(page.getByText('Großmutter Maria').first()).toBeInTheDocument(); }); it('shows unknown fallback when sender is null', async () => { render(DocumentRow, { item: makeItem() }); const unknownElements = page.getByText('Unbekannt'); await expect.element(unknownElements.first()).toBeInTheDocument(); }); it('highlights the sender when senderMatched is true', async () => { const item = makeItem({ document: { ...makeItem().document, sender: { id: 's1', displayName: 'Großmutter Maria' } }, matchData: { ...makeItem().matchData, senderMatched: true } }); render(DocumentRow, { item }); const mark = page.getByRole('mark').first(); await expect.element(mark).toHaveTextContent('Großmutter Maria'); }); it('highlights a receiver when matchedReceiverIds includes its id', async () => { const item = makeItem({ document: { ...makeItem().document, receivers: [{ id: 'r1', displayName: 'Onkel Karl' }] }, matchData: { ...makeItem().matchData, matchedReceiverIds: ['r1'] } }); render(DocumentRow, { item }); const mark = page.getByRole('mark').first(); await expect.element(mark).toHaveTextContent('Onkel Karl'); }); }); // ─── Summary ───────────────────────────────────────────────────────────────── describe('DocumentRow – summary', () => { it('renders the document summary when present', async () => { const item = makeItem({ document: { ...makeItem().document, summary: 'Brief von Eugenie über die Heimreise aus dem Süden.' } }); render(DocumentRow, { item }); await expect .element(page.getByTestId('doc-summary')) .toHaveTextContent('Brief von Eugenie über die Heimreise aus dem Süden.'); }); it('does not render the summary block when summary is empty', async () => { render(DocumentRow, { item: makeItem() }); await expect.element(page.getByTestId('doc-summary')).not.toBeInTheDocument(); }); it('applies summary search-match highlight via summaryOffsets', async () => { const item = makeItem({ document: { ...makeItem().document, summary: 'Brief über Menton' }, matchData: { ...makeItem().matchData, summaryOffsets: [{ start: 11, length: 6 }] } }); render(DocumentRow, { item }); const mark = page.getByRole('mark').first(); await expect.element(mark).toHaveTextContent('Menton'); }); }); // ─── Archive chips ─────────────────────────────────────────────────────────── describe('DocumentRow – archive chips', () => { it('renders the archive box chip when set', async () => { const item = makeItem({ document: { ...makeItem().document, archiveBox: 'K3' } }); render(DocumentRow, { item }); await expect.element(page.getByText('K3')).toBeInTheDocument(); }); it('renders the archive folder chip when set', async () => { const item = makeItem({ document: { ...makeItem().document, archiveFolder: 'Mappe A' } }); render(DocumentRow, { item }); await expect.element(page.getByText('Mappe A')).toBeInTheDocument(); }); it('renders the location chip when meta_location is set', async () => { const item = makeItem({ document: { ...makeItem().document, location: 'Berlin' } }); render(DocumentRow, { item }); await expect.element(page.getByText('Berlin')).toBeInTheDocument(); }); }); // ─── Tags ───────────────────────────────────────────────────────────────────── describe('DocumentRow – tags', () => { it('renders tag buttons', async () => { const item = makeItem({ document: { ...makeItem().document, tags: [{ id: 't1', name: 'Familie', color: null, parentId: null }] } }); render(DocumentRow, { item }); await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument(); }); it('navigates to /documents?tag=… on tag click', async () => { const item = makeItem({ document: { ...makeItem().document, tags: [{ id: 't1', name: 'Urlaub & Reise', color: null, parentId: null }] } }); render(DocumentRow, { item }); // Tailwind CSS isn't loaded in the vitest-browser client project, so the // `z-10` that elevates the content wrapper above the stretched-link // overlay anchor has no effect here — Playwright's coordinate-based // click would hit the anchor instead of the tag button. Fire the click // directly on the button to verify the handler logic. document.querySelector('button')?.click(); await expect .poll(() => vi.mocked(goto).mock.calls[0]?.[0]) .toBe('/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); }); }); // ─── Bulk-selection checkbox ───────────────────────────────────────────────── describe('DocumentRow – bulk selection checkbox', () => { it('does not render the checkbox when canWrite is false', async () => { render(DocumentRow, { item: makeItem(), canWrite: false }); await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument(); }); it('renders the checkbox when canWrite is true', async () => { render(DocumentRow, { item: makeItem(), canWrite: true }); await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument(); }); it('checkbox aria-label includes the document title', async () => { const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } }); render(DocumentRow, { item, canWrite: true }); await expect .element(page.getByRole('checkbox', { name: /Brief an Anna/i })) .toBeInTheDocument(); }); it('toggling the checkbox calls bulkSelectionStore.toggle', async () => { const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } }); render(DocumentRow, { item, canWrite: true }); expect(bulkSelectionStore.has('doc-42')).toBe(false); document.querySelector('input[type="checkbox"]')?.click(); await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true); }); it('checked state mirrors the store', async () => { bulkSelectionStore.add('doc-99'); const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } }); render(DocumentRow, { item, canWrite: true }); await expect.element(page.getByRole('checkbox')).toBeChecked(); }); }); // ─── ProgressRing & ContributorStack ───────────────────────────────────────── describe('DocumentRow – progress ring and contributors', () => { it('renders the completion percentage label', async () => { const item = makeItem({ completionPercentage: 42 }); render(DocumentRow, { item }); await expect.element(page.getByText('42%').first()).toBeInTheDocument(); }); it('renders contributor initials when contributors present', async () => { const item = makeItem({ contributors: [{ initials: 'AR', color: '#4a90e2', name: 'Anna Raddatz' }] }); render(DocumentRow, { item }); await expect.element(page.getByText('AR').first()).toBeInTheDocument(); }); });