From aaffee28043ba499a16bd03bc526a051746f15b2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 20:38:53 +0200 Subject: [PATCH] test(frontend): add Vitest specs for DocumentMetadataDrawer and TranscriptionBlock DocumentMetadataDrawer (10 tests): - Renders formatted date, dash for null date - Renders location, dash for null location - Renders translated status label - Person cards as links to /persons/{id} - Receiver links, empty state for no persons - Tag chips as links, empty state for no tags TranscriptionBlock (12 tests): - Renders block number, text, optional label - Save states: idle (nothing), saving (pulse), saved (checkmark), error (retry) - Active turquoise border, error red border - onTextChange fires on typing, onFocus fires on click Fixes @Felix/@Sara: "Frontend component tests still missing" Co-Authored-By: Claude Sonnet 4.6 --- .../DocumentMetadataDrawer.svelte.spec.ts | 100 +++++++++++++++ .../TranscriptionBlock.svelte.spec.ts | 114 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts create mode 100644 frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts diff --git a/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts new file mode 100644 index 00000000..7265a9a9 --- /dev/null +++ b/frontend/src/lib/components/DocumentMetadataDrawer.svelte.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte'; + +afterEach(cleanup); + +const sender = { id: 's1', firstName: 'Karl', lastName: 'Müller' }; +const receivers = [ + { id: 'r1', firstName: 'Anna', lastName: 'Schmidt' }, + { id: 'r2', firstName: 'Hans', lastName: 'Weber' } +]; +const tags = [ + { id: 't1', name: 'Familienbrief' }, + { id: 't2', name: 'Kriegszeit' } +]; + +function renderDrawer(overrides: Record = {}) { + return render(DocumentMetadataDrawer, { + documentDate: '1942-03-15', + location: 'Berlin', + status: 'UPLOADED', + sender, + receivers, + tags, + ...overrides + }); +} + +// ─── Details column ────────────────────────────────────────────────────────── + +describe('DocumentMetadataDrawer — details column', () => { + it('renders formatted date', async () => { + renderDrawer(); + await expect.element(page.getByText('15. März 1942')).toBeInTheDocument(); + }); + + it('renders dash when date is null', async () => { + renderDrawer({ documentDate: null }); + const dds = page.getByText('—'); + await expect.element(dds.first()).toBeInTheDocument(); + }); + + it('renders location', async () => { + renderDrawer(); + await expect.element(page.getByText('Berlin')).toBeInTheDocument(); + }); + + it('renders dash when location is null', async () => { + renderDrawer({ location: null }); + const dashes = page.getByText('—'); + await expect.element(dashes.first()).toBeInTheDocument(); + }); + + it('renders translated status label', async () => { + renderDrawer(); + // "Hochgeladen" is the German translation of UPLOADED + await expect.element(page.getByText('Hochgeladen')).toBeInTheDocument(); + }); +}); + +// ─── Persons column ────────────────────────────────────────────────────────── + +describe('DocumentMetadataDrawer — persons column', () => { + it('renders sender name as link to person detail', async () => { + renderDrawer(); + const link = page.getByRole('link', { name: /Karl Müller/ }); + await expect.element(link).toBeInTheDocument(); + await expect.element(link).toHaveAttribute('href', '/persons/s1'); + }); + + it('renders receiver names as links', async () => { + renderDrawer(); + const anna = page.getByRole('link', { name: /Anna Schmidt/ }); + await expect.element(anna).toHaveAttribute('href', '/persons/r1'); + const hans = page.getByRole('link', { name: /Hans Weber/ }); + await expect.element(hans).toHaveAttribute('href', '/persons/r2'); + }); + + it('shows empty state when no sender and no receivers', async () => { + renderDrawer({ sender: null, receivers: [] }); + await expect.element(page.getByText('Keine Personen zugeordnet')).toBeInTheDocument(); + }); +}); + +// ─── Tags column ───────────────────────────────────────────────────────────── + +describe('DocumentMetadataDrawer — tags column', () => { + it('renders tag chips as links', async () => { + renderDrawer(); + const fb = page.getByRole('link', { name: 'Familienbrief' }); + await expect.element(fb).toBeInTheDocument(); + await expect.element(fb).toHaveAttribute('href', '/?tag=Familienbrief'); + }); + + it('shows empty state when no tags', async () => { + renderDrawer({ tags: [] }); + await expect.element(page.getByText('Keine Schlagwörter zugeordnet')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts new file mode 100644 index 00000000..e3158023 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TranscriptionBlock from './TranscriptionBlock.svelte'; + +afterEach(cleanup); + +function renderBlock(overrides: Record = {}) { + return render(TranscriptionBlock, { + blockId: 'block-1', + blockNumber: 3, + text: 'Liebe Mutter,', + label: null, + active: false, + saveState: 'idle' as const, + onTextChange: vi.fn(), + onFocus: vi.fn(), + onDeleteClick: vi.fn(), + onRetry: vi.fn(), + ...overrides + }); +} + +// ─── Rendering ─────────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — rendering', () => { + it('renders block number in turquoise badge', async () => { + renderBlock(); + await expect.element(page.getByText('3')).toBeInTheDocument(); + }); + + it('renders text in textarea', async () => { + renderBlock(); + const textarea = page.getByRole('textbox'); + await expect.element(textarea).toHaveValue('Liebe Mutter,'); + }); + + it('renders optional label when provided', async () => { + renderBlock({ label: 'Anrede' }); + await expect.element(page.getByText('Anrede')).toBeInTheDocument(); + }); + + it('does not render label when null', async () => { + renderBlock({ label: null }); + const label = page.getByText('Anrede'); + await expect.element(label).not.toBeInTheDocument(); + }); +}); + +// ─── Save states ───────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — save states', () => { + it('shows nothing in idle state', async () => { + renderBlock({ saveState: 'idle' }); + const saving = page.getByText('Speichere...'); + await expect.element(saving).not.toBeInTheDocument(); + }); + + it('shows "Speichere..." in saving state', async () => { + renderBlock({ saveState: 'saving' }); + await expect.element(page.getByText('Speichere...')).toBeInTheDocument(); + }); + + it('shows "Gespeichert" in saved state', async () => { + renderBlock({ saveState: 'saved' }); + await expect.element(page.getByText(/Gespeichert/)).toBeInTheDocument(); + }); + + it('shows error with retry button in error state', async () => { + const onRetry = vi.fn(); + renderBlock({ saveState: 'error', onRetry }); + await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument(); + const retryBtn = page.getByText('Erneut versuchen'); + await expect.element(retryBtn).toBeInTheDocument(); + }); +}); + +// ─── Active state ──────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — active border', () => { + it('has turquoise left border when active', async () => { + renderBlock({ active: true }); + await expect.element(page.getByRole('textbox')).toBeInTheDocument(); + const block = document.querySelector('[data-block-id="block-1"]')!; + expect(block.className).toContain('border-turquoise'); + }); + + it('has error left border when save failed', async () => { + renderBlock({ saveState: 'error' }); + await expect.element(page.getByRole('textbox')).toBeInTheDocument(); + const block = document.querySelector('[data-block-id="block-1"]')!; + expect(block.className).toContain('border-error'); + }); +}); + +// ─── Interactions ──────────────────────────────────────────────────────────── + +describe('TranscriptionBlock — interactions', () => { + it('calls onTextChange when typing in textarea', async () => { + const onTextChange = vi.fn(); + renderBlock({ onTextChange }); + const textarea = page.getByRole('textbox'); + await textarea.fill('Neue Zeile'); + expect(onTextChange).toHaveBeenCalled(); + }); + + it('calls onFocus when textarea is focused', async () => { + const onFocus = vi.fn(); + renderBlock({ onFocus }); + const textarea = page.getByRole('textbox'); + await textarea.click(); + expect(onFocus).toHaveBeenCalled(); + }); +});