Files
familienarchiv/frontend/src/lib/document/DocumentRow.svelte.spec.ts
Marcel bca3f34cec feat(documents): badge undated rows instead of a bare em-dash
DocumentRow rendered a bare em-dash for null-dated letters — a glyph a
screen reader announces as nothing. Both breakpoints now render the single
DocumentDate component unconditionally (no {#if}/—/{:else}), so the cue
cannot drift; its unknown state is a neutral metadata chip ("Datum
unbekannt", text-ink-3, ≥4.5:1 both themes) with a non-color calendar glyph,
never red/amber. Present dates render at honest precision via
formatDocumentDate ("Juni 1916", not a fabricated day).

Refs #668
2026-05-27 18:48:45 +02:00

342 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 DocumentListItem = components['schemas']['DocumentListItem'];
function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
return {
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
documentDate: '2024-03-15',
metaDatePrecision: 'DAY',
sender: undefined,
receivers: [],
tags: [],
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({ title: null as unknown as string });
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({
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');
});
});
// ─── Date rendering (#668) ──────────────────────────────────────────────────
describe('DocumentRow date rendering', () => {
it('renders a "Datum unbekannt" badge for an undated document', async () => {
const item = makeItem({ documentDate: undefined, metaDatePrecision: 'UNKNOWN' });
render(DocumentRow, { item });
// The badge text appears (once per breakpoint block).
await expect.element(page.getByText('Datum unbekannt').first()).toBeInTheDocument();
});
it('does not render a bare em-dash for an undated document', async () => {
const item = makeItem({ documentDate: undefined, metaDatePrecision: 'UNKNOWN' });
render(DocumentRow, { item });
await expect.element(page.getByText('—', { exact: true }).first()).not.toBeInTheDocument();
});
it('renders the full date for a day-precision document', async () => {
const item = makeItem({ documentDate: '1943-12-24', metaDatePrecision: 'DAY' });
render(DocumentRow, { item });
await expect.element(page.getByText(/24\. Dezember 1943/).first()).toBeInTheDocument();
});
it('renders month precision honestly without fabricating a day', async () => {
const item = makeItem({ documentDate: '1916-06-01', metaDatePrecision: 'MONTH' });
render(DocumentRow, { item });
await expect.element(page.getByText(/Juni 1916/).first()).toBeInTheDocument();
});
});
// ─── 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({
sender: {
id: 's1',
lastName: 'Maria',
displayName: 'Großmutter Maria',
personType: 'PERSON',
familyMember: false
}
});
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({
sender: {
id: 's1',
lastName: 'Maria',
displayName: 'Großmutter Maria',
personType: 'PERSON',
familyMember: false
},
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({
receivers: [
{
id: 'r1',
lastName: 'Karl',
displayName: 'Onkel Karl',
personType: 'PERSON',
familyMember: false
}
],
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({
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({
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({ archiveBox: 'K3' });
render(DocumentRow, { item });
await expect.element(page.getByText('K3')).toBeInTheDocument();
});
it('renders the archive folder chip when set', async () => {
const item = makeItem({ 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({ location: 'Berlin' });
render(DocumentRow, { item });
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
});
});
// ─── Tags ─────────────────────────────────────────────────────────────────────
describe('DocumentRow tags', () => {
it('renders tag buttons', async () => {
const item = makeItem({
tags: [{ id: 't1', name: 'Familie' }]
});
render(DocumentRow, { item });
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
});
it('navigates to /documents?tag=… on tag click', async () => {
const item = makeItem({
tags: [{ id: 't1', name: 'Urlaub & Reise' }]
});
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({
tags: [{ id: 't2', name: 'Familie' }]
});
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({ 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({ 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({ 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();
});
});