Pulls the transcription-block state (load, save, delete, reviewToggle,
markAllReviewed, createFromDraw, toggleTrainingLabel, deleteAnnotation
+ derived blockNumbers / hasBlocks / lastEditedAt / annotationReloadKey)
out of documents/[id]/+page.svelte into a reusable factory in
lib/document/transcription/useTranscriptionBlocks.svelte.ts.
The page now reads transcription.blocks / .blockNumbers / .hasBlocks /
.lastEditedAt / .annotationReloadKey reactively and delegates writes
to transcription.{load, save, delete, reviewToggle, markAllReviewed,
createFromDraw, toggleTrainingLabel, deleteAnnotation,
findByAnnotationId, bumpAnnotationReloadKey}. The confirm-then-delete
dialog stays in the page; the hook only handles the data ops.
24 unit tests cover initial state, load (success / non-OK / network /
empty-id), derived state (blockNumbers in sortOrder, lastEditedAt
recent-pick, lastEditedAt-null fallback), delete (success bumps key /
non-OK throws), reviewToggle (success updates / non-OK no-op), markAll
(success / non-OK), createFromDraw (success / non-OK / network all
return correct shape), toggleTrainingLabel (200 / 500), deleteAnnotation
(linked-block path / orphan-annotation path / orphan-fail throw),
findByAnnotationId match + miss, bumpAnnotationReloadKey.
Also bumps the polling-loop test waits in useOcrJob.svelte.test.ts to
150-200ms (from 60-80ms) so the suite is reliable when run in parallel.
Refs #496.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
445 lines
13 KiB
TypeScript
445 lines
13 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { createOcrJob } from './useOcrJob.svelte';
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function makeFetch(handlers: Record<string, () => Response | Promise<Response>>) {
|
|
return vi.fn(async (url: RequestInfo | URL) => {
|
|
const u = url.toString();
|
|
for (const [match, fn] of Object.entries(handlers)) {
|
|
if (u.includes(match)) return fn();
|
|
}
|
|
return new Response('not found', { status: 404 });
|
|
});
|
|
}
|
|
|
|
describe('createOcrJob — initial state', () => {
|
|
it('starts not running with empty progress and error', () => {
|
|
const job = createOcrJob({ documentId: () => 'doc-1' });
|
|
expect(job.running).toBe(false);
|
|
expect(job.progressMessage).toBe('');
|
|
expect(job.errorMessage).toBe('');
|
|
expect(job.skippedPages).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('createOcrJob.triggerOcr', () => {
|
|
it('sets running=true and starts polling on 200 with jobId', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'/ocr': () =>
|
|
new Response(JSON.stringify({ jobId: 'job-7' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}),
|
|
'/ocr/jobs/job-7': () =>
|
|
new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'WORKING' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
});
|
|
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.triggerOcr('KURRENT', false);
|
|
|
|
expect(job.running).toBe(true);
|
|
expect(job.errorMessage).toBe('');
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
'/api/documents/doc-1/ocr',
|
|
expect.objectContaining({ method: 'POST' })
|
|
);
|
|
|
|
job.destroy();
|
|
});
|
|
|
|
it('sets errorMessage with generic message on 500', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'/ocr': () => new Response('boom', { status: 500 })
|
|
});
|
|
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.triggerOcr('KURRENT', false);
|
|
|
|
expect(job.running).toBe(false);
|
|
expect(job.errorMessage).toBeTruthy();
|
|
job.destroy();
|
|
});
|
|
|
|
it('extracts backend error code from 4xx body', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'/ocr': () =>
|
|
new Response(JSON.stringify({ code: 'OCR_DISABLED' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
});
|
|
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.triggerOcr('KURRENT', false);
|
|
|
|
expect(job.running).toBe(false);
|
|
expect(job.errorMessage).toBeTruthy();
|
|
// errorMessage is localized — at minimum non-empty
|
|
job.destroy();
|
|
});
|
|
|
|
it('handles non-JSON 4xx body gracefully', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'/ocr': () => new Response('not json', { status: 400 })
|
|
});
|
|
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.triggerOcr('KURRENT', false);
|
|
|
|
expect(job.running).toBe(false);
|
|
expect(job.errorMessage).toBeTruthy();
|
|
job.destroy();
|
|
});
|
|
|
|
it('handles fetch network error', async () => {
|
|
const fetchImpl = vi.fn(async () => {
|
|
throw new Error('network down');
|
|
});
|
|
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.triggerOcr('KURRENT', false);
|
|
|
|
expect(job.running).toBe(false);
|
|
expect(job.errorMessage).toBeTruthy();
|
|
job.destroy();
|
|
});
|
|
|
|
it('passes useExistingAnnotations=true in the request body', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'/ocr': () =>
|
|
new Response(JSON.stringify({ jobId: 'job-1' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}),
|
|
'/jobs/job-1': () =>
|
|
new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
});
|
|
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.triggerOcr('LATIN', true);
|
|
|
|
const triggerCall = fetchImpl.mock.calls.find(
|
|
(c) => c[0].toString().includes('/ocr') && !c[0].toString().includes('jobs')
|
|
);
|
|
expect(triggerCall).toBeDefined();
|
|
const init = (triggerCall as unknown as [string, RequestInit])[1];
|
|
const body = JSON.parse(init.body as string);
|
|
expect(body).toEqual({ scriptType: 'LATIN', useExistingAnnotations: true });
|
|
|
|
job.destroy();
|
|
});
|
|
});
|
|
|
|
describe('createOcrJob.checkStatus', () => {
|
|
it('starts polling when status is RUNNING with a jobId', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'ocr-status': () =>
|
|
new Response(JSON.stringify({ status: 'RUNNING', jobId: 'job-9' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}),
|
|
'/ocr/jobs/job-9': () =>
|
|
new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
});
|
|
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.checkStatus();
|
|
|
|
expect(job.running).toBe(true);
|
|
job.destroy();
|
|
});
|
|
|
|
it('starts polling when status is PENDING with a jobId', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'ocr-status': () =>
|
|
new Response(JSON.stringify({ status: 'PENDING', jobId: 'job-9' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
});
|
|
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.checkStatus();
|
|
|
|
expect(job.running).toBe(true);
|
|
job.destroy();
|
|
});
|
|
|
|
it('does not start polling when status is DONE', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'ocr-status': () =>
|
|
new Response(JSON.stringify({ status: 'DONE', jobId: null }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
});
|
|
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.checkStatus();
|
|
|
|
expect(job.running).toBe(false);
|
|
job.destroy();
|
|
});
|
|
|
|
it('does not start polling when no jobId present', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'ocr-status': () =>
|
|
new Response(JSON.stringify({ status: 'RUNNING', jobId: null }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
});
|
|
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.checkStatus();
|
|
|
|
expect(job.running).toBe(false);
|
|
job.destroy();
|
|
});
|
|
|
|
it('is a no-op when documentId() returns empty', async () => {
|
|
const fetchImpl = vi.fn();
|
|
const job = createOcrJob({ documentId: () => '', fetchImpl });
|
|
await job.checkStatus();
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
job.destroy();
|
|
});
|
|
|
|
it('handles 5xx ocr-status gracefully', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'ocr-status': () => new Response('boom', { status: 500 })
|
|
});
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.checkStatus();
|
|
expect(job.running).toBe(false);
|
|
job.destroy();
|
|
});
|
|
|
|
it('handles network error gracefully', async () => {
|
|
const fetchImpl = vi.fn(async () => {
|
|
throw new Error('network');
|
|
});
|
|
const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl });
|
|
await job.checkStatus();
|
|
expect(job.running).toBe(false);
|
|
job.destroy();
|
|
});
|
|
});
|
|
|
|
describe('createOcrJob — polling loop (short interval, real timers)', () => {
|
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
it('updates progressMessage from translated job code', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'/api/documents/doc-1/ocr': () =>
|
|
new Response(JSON.stringify({ jobId: 'job-1' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}),
|
|
'/api/ocr/jobs/job-1': () =>
|
|
new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'PREPARING' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
});
|
|
|
|
const job = createOcrJob({
|
|
documentId: () => 'doc-1',
|
|
fetchImpl,
|
|
pollIntervalMs: 20
|
|
});
|
|
await job.triggerOcr('KURRENT', false);
|
|
await wait(150);
|
|
|
|
expect(job.progressMessage).not.toBe('');
|
|
job.destroy();
|
|
});
|
|
|
|
it('captures skippedPages from job result', async () => {
|
|
const fetchImpl = makeFetch({
|
|
'/api/documents/doc-1/ocr': () =>
|
|
new Response(JSON.stringify({ jobId: 'job-1' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}),
|
|
'/api/ocr/jobs/job-1': () =>
|
|
new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'SKIPPED:5' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
})
|
|
});
|
|
|
|
const job = createOcrJob({
|
|
documentId: () => 'doc-1',
|
|
fetchImpl,
|
|
pollIntervalMs: 20
|
|
});
|
|
await job.triggerOcr('KURRENT', false);
|
|
await wait(150);
|
|
|
|
expect(job.skippedPages).toBeGreaterThanOrEqual(0);
|
|
job.destroy();
|
|
});
|
|
|
|
it('calls onJobFinished("DONE") when polling sees status=DONE', async () => {
|
|
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
|
|
const u = url.toString();
|
|
if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
|
|
return new Response(JSON.stringify({ jobId: 'job-1' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({ status: 'DONE', progressMessage: '' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
});
|
|
|
|
const onJobFinished = vi.fn().mockResolvedValue(undefined);
|
|
const job = createOcrJob({
|
|
documentId: () => 'doc-1',
|
|
fetchImpl,
|
|
onJobFinished,
|
|
pollIntervalMs: 20,
|
|
resetDelayMs: 10
|
|
});
|
|
await job.triggerOcr('KURRENT', false);
|
|
await wait(200);
|
|
|
|
expect(onJobFinished).toHaveBeenCalledWith('DONE');
|
|
job.destroy();
|
|
});
|
|
|
|
it('sets errorMessage and calls onJobFinished("FAILED") when polling sees status=FAILED', async () => {
|
|
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
|
|
const u = url.toString();
|
|
if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
|
|
return new Response(JSON.stringify({ jobId: 'job-1' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({ status: 'FAILED', progressMessage: '' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
});
|
|
|
|
const onJobFinished = vi.fn().mockResolvedValue(undefined);
|
|
const job = createOcrJob({
|
|
documentId: () => 'doc-1',
|
|
fetchImpl,
|
|
onJobFinished,
|
|
pollIntervalMs: 20,
|
|
resetDelayMs: 10
|
|
});
|
|
await job.triggerOcr('KURRENT', false);
|
|
await wait(200);
|
|
|
|
expect(onJobFinished).toHaveBeenCalledWith('FAILED');
|
|
expect(job.errorMessage).toBeTruthy();
|
|
job.destroy();
|
|
});
|
|
|
|
it('ignores non-OK polling responses', async () => {
|
|
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
|
|
const u = url.toString();
|
|
if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
|
|
return new Response(JSON.stringify({ jobId: 'job-1' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
return new Response('boom', { status: 500 });
|
|
});
|
|
|
|
const job = createOcrJob({
|
|
documentId: () => 'doc-1',
|
|
fetchImpl,
|
|
pollIntervalMs: 20
|
|
});
|
|
await job.triggerOcr('KURRENT', false);
|
|
await wait(150);
|
|
|
|
expect(job.running).toBe(true);
|
|
job.destroy();
|
|
});
|
|
|
|
it('swallows polling fetch network errors', async () => {
|
|
let triggered = false;
|
|
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
|
|
const u = url.toString();
|
|
if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
|
|
triggered = true;
|
|
return new Response(JSON.stringify({ jobId: 'job-1' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
if (triggered) throw new Error('network');
|
|
return new Response('', { status: 200 });
|
|
});
|
|
|
|
const job = createOcrJob({
|
|
documentId: () => 'doc-1',
|
|
fetchImpl,
|
|
pollIntervalMs: 20
|
|
});
|
|
await job.triggerOcr('KURRENT', false);
|
|
await wait(150);
|
|
|
|
expect(job.running).toBe(true);
|
|
job.destroy();
|
|
});
|
|
});
|
|
|
|
describe('createOcrJob.destroy', () => {
|
|
it('stops polling and is safe to call without an active job', () => {
|
|
const job = createOcrJob({ documentId: () => 'doc-1' });
|
|
expect(() => job.destroy()).not.toThrow();
|
|
});
|
|
|
|
it('stops the polling interval when called mid-poll', async () => {
|
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
const fetchImpl = vi.fn(async (url: RequestInfo | URL) => {
|
|
const u = url.toString();
|
|
if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) {
|
|
return new Response(JSON.stringify({ jobId: 'job-1' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
});
|
|
|
|
const job = createOcrJob({
|
|
documentId: () => 'doc-1',
|
|
fetchImpl,
|
|
pollIntervalMs: 20
|
|
});
|
|
await job.triggerOcr('KURRENT', false);
|
|
job.destroy();
|
|
|
|
const callsAtDestroy = fetchImpl.mock.calls.length;
|
|
await wait(200);
|
|
// No additional fetch calls after destroy
|
|
expect(fetchImpl.mock.calls.length).toBe(callsAtDestroy);
|
|
});
|
|
});
|