Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


test/ocr-job.test.ts (10575 bytes)

1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2 import type { OcrToken } from '../src/align';
3 import {
4   createOcrJob,
5   OcrStartError,
6   OcrTimeoutError,
7   pageTimeLimit,
8   START_LIMIT_MS,
9   stopOcr,
10   type OcrWorker,
11 } from '../src/ocr-job';
12 
13 /** The reasons of the unhandled rejections while `run` runs, and 20 ms after it. */
14 async function unhandledDuring(run: () => void): Promise<unknown[]> {
15   const reasons: unknown[] = [];
16   const record = (reason: unknown): void => {
17     reasons.push(reason);
18   };
19   process.on('unhandledRejection', record);
20   try {
21     run();
22     await new Promise((resolve) => setTimeout(resolve, 20));
23   } finally {
24     process.off('unhandledRejection', record);
25   }
26   return reasons;
27 }
28 
29 describe('stopOcr', () => {
30   afterEach(() => vi.restoreAllMocks());
31 
32   it('stops the OCR that started', async () => {
33     const terminate = vi.fn(() => Promise.resolve());
34     stopOcr(Promise.resolve({ terminate }));
35     await new Promise((resolve) => setTimeout(resolve, 0));
36     expect(terminate).toHaveBeenCalledTimes(1);
37   });
38 
39   it('does nothing when no OCR started', () => {
40     expect(() => stopOcr(undefined)).not.toThrow();
41   });
42 
43   it('gives no unhandled rejection when the OCR could not start', async () => {
44     // The web finding: the reading ended with an unhandled rejection, and the
45     // progress stayed in the strip.
46     const failed = Promise.reject(new OcrStartError('TypeError: Failed to fetch'));
47     expect(await unhandledDuring(() => void stopOcr(failed))).toEqual([]);
48   });
49 
50   it('gives no unhandled rejection when the stop fails', async () => {
51     const terminate = vi.fn(() => Promise.reject(new Error('The worker is gone.')));
52     expect(await unhandledDuring(() => void stopOcr(Promise.resolve({ terminate })))).toEqual([]);
53   });
54 });
55 
56 describe('pageTimeLimit', () => {
57   it('gives a page of the app 120 s', () => {
58     expect(pageTimeLimit(1600 * 2263)).toBe(120_000);
59     expect(pageTimeLimit(1)).toBe(120_000);
60   });
61 
62   it('gives a larger page more time, in proportion to its area', () => {
63     expect(pageTimeLimit(8_000_000)).toBe(240_000);
64     expect(pageTimeLimit(1600 * 10_000)).toBe(480_000);
65   });
66 });
67 
68 /** A token of page `page`, so that a result shows its page. */
69 function token(page: number): OcrToken {
70   return { text: 'a', page, line: 0, word: 0, bbox: { x0: 0, y0: 0, x1: 1, y1: 1 } };
71 }
72 
73 /** A fake OCR worker. `read` gives the result of each page. */
74 function fakeWorker(read: (page: number) => Promise<OcrToken[]>) {
75   return {
76     recognize: vi.fn((_image: string, page: number) => read(page)),
77     terminate: vi.fn(() => Promise.resolve()),
78   } satisfies OcrWorker<string>;
79 }
80 
81 /** A page that never ends: tesseract.js after a crash of its worker. */
82 const never = (): Promise<OcrToken[]> => new Promise(() => undefined);
83 
84 const readPage = (page: number): Promise<OcrToken[]> => Promise.resolve([token(page)]);
85 
86 /** A fake worker whose page rejects when the worker stops, as in src/ocr.ts. */
87 function stoppableWorker() {
88   let stop: (reason: Error) => void = () => undefined;
89   const stopped = new Promise<never>((_resolve, reject) => {
90     stop = reject;
91   });
92   stopped.catch(() => undefined);
93   const worker = fakeWorker(() => stopped);
94   worker.terminate.mockImplementation(() => {
95     stop(new Error('OCR stopped.'));
96     return Promise.resolve();
97   });
98   return worker;
99 }
100 
101 describe('createOcrJob', () => {
102   beforeEach(() => vi.useFakeTimers());
103   afterEach(() => vi.useRealTimers());
104 
105   it('stops a worker whose page runs too long, and reads the next page with a new worker', async () => {
106     // The finding: a crashed tesseract worker does not settle its page, and
107     // the reading waited for ever.
108     const hung = fakeWorker(never);
109     const next = fakeWorker(readPage);
110     const start = vi
111       .fn<() => Promise<OcrWorker<string>>>()
112       .mockResolvedValueOnce(hung)
113       .mockResolvedValueOnce(next);
114     const job = createOcrJob(start);
115     let settled = false;
116     const page = job
117       .recognize('page 1', 0, 1000)
118       .catch((e: unknown) => e)
119       .finally(() => {
120         settled = true;
121       });
122     await vi.advanceTimersByTimeAsync(999);
123     expect(settled).toBe(false);
124     expect(hung.terminate).not.toHaveBeenCalled();
125     await vi.advanceTimersByTimeAsync(1);
126     const error = await page;
127     expect(error).toBeInstanceOf(OcrTimeoutError);
128     expect((error as Error).message).toBe('The OCR of page 1 took more than 1 s.');
129     expect(hung.terminate).toHaveBeenCalledTimes(1);
130     await expect(job.recognize('page 2', 1, 1000)).resolves.toEqual([token(1)]);
131     expect(start).toHaveBeenCalledTimes(2);
132     expect(vi.getTimerCount()).toBe(0);
133   });
134 
135   it('stops a worker whose page fails, and reads the next page with a new worker', async () => {
136     // An error event of the worker, or an abort of its WebAssembly, rejects the page.
137     const failed = fakeWorker(() => Promise.reject(new Error('The OCR worker failed.')));
138     const next = fakeWorker(readPage);
139     const start = vi
140       .fn<() => Promise<OcrWorker<string>>>()
141       .mockResolvedValueOnce(failed)
142       .mockResolvedValueOnce(next);
143     const job = createOcrJob(start);
144     await expect(job.recognize('page 1', 0, 1000)).rejects.toThrow('The OCR worker failed.');
145     expect(failed.terminate).toHaveBeenCalledTimes(1);
146     await expect(job.recognize('page 2', 1, 1000)).resolves.toEqual([token(1)]);
147     expect(start).toHaveBeenCalledTimes(2);
148     expect(vi.getTimerCount()).toBe(0);
149   });
150 
151   it('keeps the worker for the next page when a page is read in time', async () => {
152     const worker = fakeWorker(readPage);
153     const start = vi.fn(() => Promise.resolve(worker));
154     const job = createOcrJob(start);
155     await job.start();
156     await expect(job.recognize('page 1', 0, 1000)).resolves.toEqual([token(0)]);
157     await expect(job.recognize('page 2', 1, 1000)).resolves.toEqual([token(1)]);
158     expect(start).toHaveBeenCalledTimes(1);
159     expect(worker.terminate).not.toHaveBeenCalled();
160     expect(vi.getTimerCount()).toBe(0);
161   });
162 
163   it('gives each page the start error, and does not start again', async () => {
164     const error = new OcrStartError('TypeError: Failed to fetch');
165     const start = vi.fn(() => Promise.reject(error));
166     const job = createOcrJob(start);
167     await expect(job.start()).rejects.toBe(error);
168     await expect(job.recognize('page 1', 0, 1000)).rejects.toBe(error);
169     expect(start).toHaveBeenCalledTimes(1);
170   });
171 
172   /** A start that never ends, as a stalled download. It keeps the signal of each start. */
173   function stalledStart() {
174     const signals: AbortSignal[] = [];
175     const start = vi.fn((signal: AbortSignal) => {
176       signals.push(signal);
177       return new Promise<OcrWorker<string>>(() => undefined);
178     });
179     return { start, signals };
180   }
181 
182   it('gives the start error when the start takes longer than its limit, and stops the start', async () => {
183     // The finding: a download of the language data that stalls (a captive
184     // portal) held the start, and so the reading, for ever, with no Retry.
185     const { start, signals } = stalledStart();
186     const job = createOcrJob(start, 1000);
187     let settled = false;
188     const started = job
189       .start()
190       .catch((e: unknown) => e)
191       .finally(() => {
192         settled = true;
193       });
194     await vi.advanceTimersByTimeAsync(999);
195     expect(settled).toBe(false);
196     await vi.advanceTimersByTimeAsync(1);
197     expect(settled).toBe(true);
198     const error = await started;
199     expect(error).toBeInstanceOf(OcrStartError);
200     expect((error as Error).message).toBe(
201       'OCR could not start: Error: The start took more than 1 s.',
202     );
203     // The start of the worker gets the stop, so that it can stop its worker.
204     expect(signals[0]?.aborted).toBe(true);
205     expect(signals[0]?.reason).toBe(error);
206     // Each page gets the same start error. No new start runs.
207     await expect(job.recognize('page 1', 0, 1000)).rejects.toBe(error);
208     expect(start).toHaveBeenCalledTimes(1);
209     expect(vi.getTimerCount()).toBe(0);
210   });
211 
212   it('gives the start error also where abort() keeps no reason (WebView before 98)', async () => {
213     // abort(reason) and AbortSignal.reason came in Chrome 98. The SIMD core of
214     // the app runs from WebView 91.
215     const abort = AbortController.prototype.abort;
216     const noReason = vi.spyOn(AbortController.prototype, 'abort').mockImplementation(function (
217       this: AbortController,
218     ) {
219       abort.call(this);
220     });
221     try {
222       const { start } = stalledStart();
223       const job = createOcrJob(start, 1000);
224       const started = job.start().catch((e: unknown) => e);
225       await vi.advanceTimersByTimeAsync(1000);
226       expect(await started).toBeInstanceOf(OcrStartError);
227     } finally {
228       noReason.mockRestore();
229     }
230   });
231 
232   it('stops a worker that starts after the start limit', async () => {
233     const worker = fakeWorker(readPage);
234     const start = vi.fn(
235       () => new Promise<OcrWorker<string>>((resolve) => setTimeout(() => resolve(worker), 2000)),
236     );
237     const job = createOcrJob(start, 1000);
238     const started = job.start().catch((e: unknown) => e);
239     await vi.advanceTimersByTimeAsync(1000);
240     expect(await started).toBeInstanceOf(OcrStartError);
241     expect(worker.terminate).not.toHaveBeenCalled();
242     await vi.advanceTimersByTimeAsync(1000);
243     expect(worker.terminate).toHaveBeenCalledTimes(1);
244   });
245 
246   it('stops a start that runs when the job stops', async () => {
247     const { start, signals } = stalledStart();
248     const job = createOcrJob(start, 1000);
249     const started = job.start().catch((e: unknown) => e);
250     job.stop();
251     expect(signals[0]?.aborted).toBe(true);
252     expect(await started).toEqual(new Error('OCR stopped.'));
253     expect(vi.getTimerCount()).toBe(0);
254   });
255 
256   it('gives the start the limit of a page by default', () => {
257     expect(START_LIMIT_MS).toBe(pageTimeLimit(0));
258   });
259 
260   it('rejects the page that runs on stop, stops the worker once, and starts no new worker', async () => {
261     const worker = stoppableWorker();
262     const start = vi.fn(() => Promise.resolve(worker));
263     const job = createOcrJob(start);
264     const page = job.recognize('page 1', 0, 1000).catch((e: unknown) => e);
265     await vi.advanceTimersByTimeAsync(0);
266     job.stop();
267     expect(await page).toEqual(new Error('OCR stopped.'));
268     expect(worker.terminate).toHaveBeenCalledTimes(1);
269     await expect(job.recognize('page 2', 1, 1000)).rejects.toThrow('OCR stopped.');
270     expect(start).toHaveBeenCalledTimes(1);
271     expect(vi.getTimerCount()).toBe(0);
272   });
273 });