refactor: move document domain core to lib/document/
Moves ~25 components, utils (search, filename, groupDocuments, documentStatusLabel, validateFile), bulkSelection store, and TranscriptionSection sub-component. Fixes broken relative imports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
325
frontend/src/lib/document/DocumentRow.svelte.spec.ts
Normal file
325
frontend/src/lib/document/DocumentRow.svelte.spec.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
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> = {}): 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<HTMLButtonElement>('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<HTMLInputElement>('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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user