From bdcf813e71026a14b4d7da8754be1f7be0c91820 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 10 May 2026 00:47:32 +0200 Subject: [PATCH] test(ocr): cover OcrProgress SSE state branches Running default render, progress bar element, document event updates aria-valuenow, done event triggers onDone + clears running view, error event flips heading, retry button in error state. Mocks EventSource via Proxy so the SSE effect uses a stub, not a real connection. 6 tests, ~15 branches. Refs #496. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/ocr/OcrProgress.svelte.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 frontend/src/lib/ocr/OcrProgress.svelte.test.ts diff --git a/frontend/src/lib/ocr/OcrProgress.svelte.test.ts b/frontend/src/lib/ocr/OcrProgress.svelte.test.ts new file mode 100644 index 00000000..ec343d2d --- /dev/null +++ b/frontend/src/lib/ocr/OcrProgress.svelte.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import OcrProgress from './OcrProgress.svelte'; + +// Mock EventSource so the $effect doesn't open a real SSE connection. +class MockEventSource { + url: string; + listeners = new Map(); + onerror: (() => void) | null = null; + close = vi.fn(); + constructor(url: string) { + this.url = url; + } + addEventListener(type: string, fn: EventListener) { + this.listeners.set(type, fn); + } + dispatch(type: string, data: unknown) { + const fn = this.listeners.get(type); + if (fn) fn({ data: JSON.stringify(data) } as MessageEvent); + } +} + +let lastSource: MockEventSource | null = null; + +beforeEach(() => { + const trackedFactory = function (url: string) { + const src = new MockEventSource(url); + lastSource = src; + return src; + }; + (globalThis as unknown as { EventSource: unknown }).EventSource = new Proxy(MockEventSource, { + construct(_target, args) { + return trackedFactory(args[0]); + } + }); +}); + +afterEach(() => { + cleanup(); + lastSource = null; +}); + +describe('OcrProgress', () => { + it('renders the running progress block by default', async () => { + render(OcrProgress, { props: { jobId: 'job-1', onDone: () => {} } }); + + await expect.element(page.getByRole('heading', { name: /ocr läuft/i })).toBeVisible(); + }); + + it('renders the progress bar with the running label', async () => { + render(OcrProgress, { props: { jobId: 'job-1', onDone: () => {} } }); + + expect(document.querySelector('[role="progressbar"]')).not.toBeNull(); + }); + + it('updates the progress bar when document events arrive', async () => { + render(OcrProgress, { props: { jobId: 'job-1', onDone: () => {} } }); + + // Wait a tick for $effect to run and EventSource to be created. + await new Promise((r) => setTimeout(r, 50)); + lastSource?.dispatch('document', { processed: 5, total: 10 }); + await new Promise((r) => setTimeout(r, 50)); + + const bar = (await page.getByRole('progressbar').element()) as HTMLElement; + expect(bar.getAttribute('aria-valuenow')).toBe('50'); + }); + + it('switches to the done state and calls onDone when the done event arrives', async () => { + const onDone = vi.fn(); + render(OcrProgress, { props: { jobId: 'job-1', onDone } }); + + await new Promise((r) => setTimeout(r, 50)); + lastSource?.dispatch('done', {}); + await new Promise((r) => setTimeout(r, 50)); + + expect(onDone).toHaveBeenCalledOnce(); + await expect.element(page.getByRole('heading', { name: /ocr läuft/i })).not.toBeInTheDocument(); + }); + + it('switches to the error state when the error event arrives', async () => { + render(OcrProgress, { props: { jobId: 'job-1', onDone: () => {} } }); + + await new Promise((r) => setTimeout(r, 50)); + lastSource?.dispatch('error', {}); + await new Promise((r) => setTimeout(r, 50)); + + await expect.element(page.getByRole('heading', { name: /ocr fehlgeschlagen/i })).toBeVisible(); + }); + + it('renders the retry button in the error state', async () => { + render(OcrProgress, { props: { jobId: 'job-1', onDone: () => {} } }); + + await new Promise((r) => setTimeout(r, 50)); + lastSource?.dispatch('error', {}); + await new Promise((r) => setTimeout(r, 50)); + + await expect.element(page.getByRole('button', { name: /erneut versuchen/i })).toBeVisible(); + }); +});