Files
familienarchiv/frontend/src/lib/components/DocumentRow.svelte.spec.ts
Marcel bca27898f7
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m38s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Failing after 2m55s
fix(documents): tag click no longer navigates to document detail page
Nesting the tag <button> inside the row's <a href="…"> made the browser
treat any click on the button as a click on the anchor, sending the
user to the document detail page even though the tag handler called
goto() with the tag-filter URL. e.stopPropagation() doesn't cancel
the anchor's default navigation.

Refactor to the stretched-link pattern: the row-wide anchor sits as an
overlay (`absolute inset-0 z-0`) and the content wrapper sits above it
(`relative z-10` + `pointer-events-none`). Tag buttons re-enable
pointer events with `pointer-events-auto`, so they're true siblings of
the anchor and receive their own clicks. Empty content areas pass
through to the anchor for whole-row navigation.

The vitest-browser client project doesn't load Tailwind CSS, so the
z-index has no effect there and Playwright's coordinate-based click
hits the anchor instead of the button. Trigger the click directly on
the button DOM element in the unit test (with a comment explaining the
test-env constraint); the actual user-facing behavior is verified via
playwright against the running dev server.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 09:10:20 +02:00

285 lines
9.7 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 type { components } from '$lib/generated/api';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => {
cleanup();
vi.mocked(goto).mockClear();
});
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);
});
});
// ─── 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();
});
});