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 <noreply@anthropic.com>
This commit is contained in:
100
frontend/src/lib/ocr/OcrProgress.svelte.test.ts
Normal file
100
frontend/src/lib/ocr/OcrProgress.svelte.test.ts
Normal file
@@ -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<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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user