Files
familienarchiv/frontend/src/lib/ocr/OcrProgress.svelte.test.ts
Marcel 910f890c75 test(ocr): replace 8 setTimeout sleeps in OcrProgress with vi.waitFor
waitForSource() helper polls for the EventSource constructor effect
to register the mock; assertion blocks use vi.waitFor on the progress
bar / heading / button changes after each SSE event dispatch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 21:50:28 +02:00

103 lines
3.2 KiB
TypeScript

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<string, EventListener>();
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;
});
async function waitForSource(): Promise<MockEventSource> {
await vi.waitFor(() => expect(lastSource).not.toBeNull());
return lastSource as MockEventSource;
}
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: () => {} } });
const src = await waitForSource();
src.dispatch('document', { processed: 5, total: 10 });
await vi.waitFor(async () => {
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 } });
const src = await waitForSource();
src.dispatch('done', {});
await vi.waitFor(() => 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: () => {} } });
const src = await waitForSource();
src.dispatch('error', {});
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: () => {} } });
const src = await waitForSource();
src.dispatch('error', {});
await expect.element(page.getByRole('button', { name: /erneut versuchen/i })).toBeVisible();
});
});