From ca660f103dc5cbe303ccfe3dac31028612124776 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 12:36:33 +0200 Subject: [PATCH] test(#240): add component tests for all four Mission Control Strip components 17 tests across SegmentationColumn, TranscriptionColumn, ReadyColumn, MissionControlStrip. Covers document list rendering, per-column empty states, weekly pulse visibility, link hrefs, progress bar, and the reviewedPct denominator (annotationCount, not textedBlockCount). Co-Authored-By: Claude Sonnet 4.6 --- .../MissionControlStrip.svelte.spec.ts | 105 ++++++++++++++++++ .../lib/components/ReadyColumn.svelte.spec.ts | 76 +++++++++++++ .../SegmentationColumn.svelte.spec.ts | 65 +++++++++++ .../TranscriptionColumn.svelte.spec.ts | 83 ++++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 frontend/src/lib/components/MissionControlStrip.svelte.spec.ts create mode 100644 frontend/src/lib/components/ReadyColumn.svelte.spec.ts create mode 100644 frontend/src/lib/components/SegmentationColumn.svelte.spec.ts create mode 100644 frontend/src/lib/components/TranscriptionColumn.svelte.spec.ts diff --git a/frontend/src/lib/components/MissionControlStrip.svelte.spec.ts b/frontend/src/lib/components/MissionControlStrip.svelte.spec.ts new file mode 100644 index 00000000..7b9503dc --- /dev/null +++ b/frontend/src/lib/components/MissionControlStrip.svelte.spec.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import MissionControlStrip from './MissionControlStrip.svelte'; +import type { components } from '$lib/generated/api'; + +type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO']; +type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO']; + +afterEach(cleanup); + +function makeDoc( + id: string, + title: string, + overrides: Partial = {} +): TranscriptionQueueItemDTO { + return { + id, + title, + annotationCount: 0, + textedBlockCount: 0, + reviewedBlockCount: 0, + ...overrides + }; +} + +const emptyStats: TranscriptionWeeklyStatsDTO = { + segmentationCount: 0, + transcriptionCount: 0, + readyCount: 0 +}; + +describe('MissionControlStrip', () => { + it('renders section heading always', async () => { + render(MissionControlStrip, { + props: { + segmentationDocs: [], + transcriptionDocs: [], + readyDocs: [], + weeklyStats: null + } + }); + + await expect.element(page.getByText('Was braucht Aufmerksamkeit?')).toBeInTheDocument(); + }); + + it('renders all three column headings', async () => { + render(MissionControlStrip, { + props: { + segmentationDocs: [makeDoc('s1', 'Seg Dok')], + transcriptionDocs: [makeDoc('t1', 'Trans Dok')], + readyDocs: [makeDoc('r1', 'Ready Dok')], + weeklyStats: emptyStats + } + }); + + await expect.element(page.getByText('Rahmen einzeichnen')).toBeInTheDocument(); + await expect.element(page.getByText('Text eintippen')).toBeInTheDocument(); + await expect.element(page.getByText(/Lesefertig/)).toBeInTheDocument(); + }); + + it('renders document titles in correct columns', async () => { + const segDoc = makeDoc('seg-1', 'Segmentierungs Brief'); + const transDoc = makeDoc('trans-1', 'Transkriptions Postkarte'); + const readyDoc = makeDoc('ready-1', 'Fertiger Tagebucheintrag'); + + render(MissionControlStrip, { + props: { + segmentationDocs: [segDoc], + transcriptionDocs: [transDoc], + readyDocs: [readyDoc], + weeklyStats: emptyStats + } + }); + + await expect.element(page.getByText('Segmentierungs Brief')).toBeInTheDocument(); + await expect.element(page.getByText('Transkriptions Postkarte')).toBeInTheDocument(); + await expect.element(page.getByText('Fertiger Tagebucheintrag')).toBeInTheDocument(); + }); + + it('renders section heading even when all arrays are empty and weeklyStats is null', async () => { + render(MissionControlStrip, { + props: { + segmentationDocs: [], + transcriptionDocs: [], + readyDocs: [], + weeklyStats: null + } + }); + + // Heading always visible + await expect.element(page.getByText('Was braucht Aufmerksamkeit?')).toBeInTheDocument(); + + // All three empty states should also be visible + await expect + .element(page.getByText('Alle Dokumente haben bereits Segmentierungsblöcke.')) + .toBeInTheDocument(); + await expect + .element(page.getByText('Keine Dokumente warten auf Transkription.')) + .toBeInTheDocument(); + await expect + .element(page.getByText('Noch keine Dokumente vollständig transkribiert.')) + .toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/ReadyColumn.svelte.spec.ts b/frontend/src/lib/components/ReadyColumn.svelte.spec.ts new file mode 100644 index 00000000..02d456a9 --- /dev/null +++ b/frontend/src/lib/components/ReadyColumn.svelte.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ReadyColumn from './ReadyColumn.svelte'; +import type { components } from '$lib/generated/api'; + +type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO']; + +afterEach(cleanup); + +function makeDoc(overrides: Partial = {}): TranscriptionQueueItemDTO { + return { + id: 'doc-1', + title: 'Test Dokument', + annotationCount: 0, + textedBlockCount: 0, + reviewedBlockCount: 0, + ...overrides + }; +} + +describe('ReadyColumn', () => { + it('renders mint-themed list when docs are provided', async () => { + const doc1 = makeDoc({ id: 'doc-1', title: 'Leseферtig Brief' }); + const doc2 = makeDoc({ id: 'doc-2', title: 'Archiv Dokument' }); + + render(ReadyColumn, { props: { docs: [doc1, doc2], weeklyCount: 0 } }); + + await expect.element(page.getByText('Leseферtig Brief')).toBeInTheDocument(); + await expect.element(page.getByText('Archiv Dokument')).toBeInTheDocument(); + + // Mint-themed container should exist + const mintContainer = document.querySelector('.border-brand-mint'); + expect(mintContainer).not.toBeNull(); + }); + + it('renders dashed empty state with CTA link when docs array is empty', async () => { + render(ReadyColumn, { props: { docs: [], weeklyCount: 0 } }); + + await expect + .element(page.getByText('Noch keine Dokumente vollständig transkribiert.')) + .toBeInTheDocument(); + + const ctaLink = page.getByRole('link', { name: 'Jetzt mitmachen' }); + await expect.element(ctaLink).toBeInTheDocument(); + await expect + .element(ctaLink) + .toHaveAttribute('href', '/enrich?filter=NEEDS_SEGMENTATION&next=1'); + }); + + it('shows reviewedPct using annotationCount as denominator', async () => { + // annotationCount=4, reviewedBlockCount=4, textedBlockCount=2 + // reviewedPct = Math.round(4 / 4 * 100) = 100, NOT Math.round(4/2*100) = 200 + const doc = makeDoc({ + id: 'doc-1', + title: 'Geprüftes Dokument', + annotationCount: 4, + reviewedBlockCount: 4, + textedBlockCount: 2 + }); + + render(ReadyColumn, { props: { docs: [doc], weeklyCount: 0 } }); + + // Should show 100% (using annotationCount=4 as denominator) + await expect.element(page.getByText('100% geprüft')).toBeInTheDocument(); + }); + + it('links to /documents/{id}', async () => { + const doc = makeDoc({ id: 'ready-789', title: 'Fertiges Dokument' }); + + render(ReadyColumn, { props: { docs: [doc], weeklyCount: 0 } }); + + const link = page.getByRole('link', { name: /Fertiges Dokument/ }); + await expect.element(link).toHaveAttribute('href', '/documents/ready-789'); + }); +}); diff --git a/frontend/src/lib/components/SegmentationColumn.svelte.spec.ts b/frontend/src/lib/components/SegmentationColumn.svelte.spec.ts new file mode 100644 index 00000000..52de56af --- /dev/null +++ b/frontend/src/lib/components/SegmentationColumn.svelte.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import SegmentationColumn from './SegmentationColumn.svelte'; +import type { components } from '$lib/generated/api'; + +type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO']; + +afterEach(cleanup); + +function makeDoc(overrides: Partial = {}): TranscriptionQueueItemDTO { + return { + id: 'doc-1', + title: 'Test Dokument', + annotationCount: 0, + textedBlockCount: 0, + reviewedBlockCount: 0, + ...overrides + }; +} + +describe('SegmentationColumn', () => { + it('renders document list when docs are provided', async () => { + const doc1 = makeDoc({ id: 'doc-1', title: 'Brief an Maria' }); + const doc2 = makeDoc({ id: 'doc-2', title: 'Postkarte 1923' }); + + render(SegmentationColumn, { props: { docs: [doc1, doc2], weeklyCount: 0 } }); + + await expect.element(page.getByText('Brief an Maria')).toBeInTheDocument(); + await expect.element(page.getByText('Postkarte 1923')).toBeInTheDocument(); + }); + + it('renders dashed empty state when docs array is empty', async () => { + render(SegmentationColumn, { props: { docs: [], weeklyCount: 0 } }); + + await expect + .element(page.getByText('Alle Dokumente haben bereits Segmentierungsblöcke.')) + .toBeInTheDocument(); + }); + + it('shows weekly pulse when weeklyCount > 0', async () => { + const doc = makeDoc({ id: 'doc-1', title: 'Brief' }); + + render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 3 } }); + + await expect.element(page.getByText(/\+3 diese Woche/)).toBeInTheDocument(); + }); + + it('does not show weekly pulse when weeklyCount is 0', async () => { + const doc = makeDoc({ id: 'doc-1', title: 'Brief' }); + + render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 0 } }); + + await expect.element(page.getByText(/diese Woche/)).not.toBeInTheDocument(); + }); + + it('links to /documents/{id}', async () => { + const doc = makeDoc({ id: 'abc-123', title: 'Verlinktes Dokument' }); + + render(SegmentationColumn, { props: { docs: [doc], weeklyCount: 0 } }); + + const link = page.getByRole('link', { name: /Verlinktes Dokument/ }); + await expect.element(link).toHaveAttribute('href', '/documents/abc-123'); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionColumn.svelte.spec.ts b/frontend/src/lib/components/TranscriptionColumn.svelte.spec.ts new file mode 100644 index 00000000..170671db --- /dev/null +++ b/frontend/src/lib/components/TranscriptionColumn.svelte.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TranscriptionColumn from './TranscriptionColumn.svelte'; +import type { components } from '$lib/generated/api'; + +type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO']; + +afterEach(cleanup); + +function makeDoc(overrides: Partial = {}): TranscriptionQueueItemDTO { + return { + id: 'doc-1', + title: 'Test Dokument', + annotationCount: 0, + textedBlockCount: 0, + reviewedBlockCount: 0, + ...overrides + }; +} + +describe('TranscriptionColumn', () => { + it('renders document list when docs are provided', async () => { + const doc1 = makeDoc({ id: 'doc-1', title: 'Familienbrief' }); + const doc2 = makeDoc({ id: 'doc-2', title: 'Tagebuch Eintrag' }); + + render(TranscriptionColumn, { props: { docs: [doc1, doc2], weeklyCount: 0 } }); + + await expect.element(page.getByText('Familienbrief')).toBeInTheDocument(); + await expect.element(page.getByText('Tagebuch Eintrag')).toBeInTheDocument(); + }); + + it('renders dashed empty state when docs array is empty', async () => { + render(TranscriptionColumn, { props: { docs: [], weeklyCount: 0 } }); + + await expect + .element(page.getByText('Keine Dokumente warten auf Transkription.')) + .toBeInTheDocument(); + }); + + it('renders progress bar when textedBlockCount > 0', async () => { + const doc = makeDoc({ + id: 'doc-1', + title: 'Brief mit Blöcken', + annotationCount: 4, + textedBlockCount: 2 + }); + + render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } }); + + // The progress text should show "2 / 4 Blöcke" + await expect.element(page.getByText('2 / 4 Blöcke')).toBeInTheDocument(); + + // A progress bar div should exist (the visual bar) + const progressBar = document.querySelector('.h-1.flex-1'); + expect(progressBar).not.toBeNull(); + }); + + it('renders dash placeholder when textedBlockCount is 0', async () => { + const doc = makeDoc({ + id: 'doc-1', + title: 'Brief ohne Blöcke', + annotationCount: 3, + textedBlockCount: 0 + }); + + render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } }); + + // The italic em-dash placeholder should render + const dashEl = document.querySelector('span.italic'); + expect(dashEl).not.toBeNull(); + expect(dashEl?.textContent?.trim()).toBe('—'); + }); + + it('links to /documents/{id}', async () => { + const doc = makeDoc({ id: 'xyz-456', title: 'Transkriptions Dokument' }); + + render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } }); + + const link = page.getByRole('link', { name: /Transkriptions Dokument/ }); + await expect.element(link).toHaveAttribute('href', '/documents/xyz-456'); + }); +});