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/messages.test.ts (13995 bytes)

1 import fc from 'fast-check';
2 import { describe, expect, it } from 'vitest';
3 import { PlayerError, type ClockState } from '../src/clock-source';
4 import {
5   ANKI_RELEASES,
6   ANKI_WEB_TEXT,
7   ankiText,
8   clockText,
9   errorMessage,
10   matchedText,
11   MISMATCH_SHARE,
12   MISMATCH_TEXT,
13   noCuesText,
14   notReadText,
15   OCR_START_TEXT,
16   OVERLAY_RELEASES,
17   pagesReadText,
18   pdfLoadedText,
19   pdfOpenText,
20   playerCommandsOff,
21   playerMessage,
22   playerProblem,
23   readingEndMessage,
24   readingText,
25   keptResultText,
26   shownInPlayerPart,
27   stripTexts,
28   subreadErrorText,
29   subreadQuestion,
30   subreadResultText,
31   waitMessage,
32   waitPagesText,
33   withForceOcr,
34 } from '../src/messages';
35 
36 describe('stripTexts', () => {
37   it('gives the full text of each part that shows, for the panel of the strip', () => {
38     // The phone finding: the strip showed "No subtitle li..." next to
39     // "Allow notification acces..." and the "Open SubRead Overlay" button.
40     const player = 'Allow notification access in SubRead Overlay.';
41     const event = 'No subtitle lines in book.srt.pdf. Choose an .srt or .vtt file.';
42     expect(
43       stripTexts([
44         { shown: true, title: player, text: `${player} Open SubRead Overlay` },
45         { shown: false, title: '', text: 'Follow paused' },
46         { shown: false, title: '', text: '' },
47         { shown: true, title: event, text: event },
48       ]),
49     ).toEqual([player, event]);
50   });
51 
52   it('takes the text of a part without a title, and skips a part without text', () => {
53     expect(
54       stripTexts([
55         { shown: true, title: '', text: 'Follow paused' },
56         { shown: true, title: '', text: ' ' },
57       ]),
58     ).toEqual(['Follow paused']);
59   });
60 });
61 
62 describe('waitMessage', () => {
63   const retry = { id: 'retry-reading', text: 'Retry' };
64 
65   it('tells how far the reading is while it runs', () => {
66     expect(waitMessage(3, 40, true)).toEqual({ text: 'Reading pages 3/40 first...' });
67   });
68 
69   it('tells after the reading that SubRead waits for the pages that could not be read', () => {
70     // The finding: after "2 pages could not be read." the menu still said
71     // "Reading pages 38/40 first...", and SubRead never started.
72     expect(waitMessage(38, 40, false)).toEqual({
73       text: 'SubRead waits for 2 pages that could not be read.',
74       action: retry,
75     });
76     // OCR could not start: no page of the scan is read.
77     expect(waitMessage(0, 1, false)).toEqual({
78       text: 'SubRead waits for 1 page that could not be read.',
79       action: retry,
80     });
81   });
82 });
83 
84 function state(over: Partial<ClockState>): ClockState {
85   return { positionMs: 0, playing: false, seeked: false, error: null, ...over };
86 }
87 
88 describe('clockText', () => {
89   it('gives minutes and seconds, and hours from one hour', () => {
90     expect(clockText(0)).toBe('00:00');
91     expect(clockText(20_500)).toBe('00:20');
92     expect(clockText(3_599_999)).toBe('59:59');
93     expect(clockText(3_600_000 + 61_000)).toBe('1:01:01');
94   });
95 });
96 
97 describe('playerProblem', () => {
98   it('tells what to do for each reason, on Android and on the web', () => {
99     expect(playerProblem('no_player', true)).toEqual({
100       text: 'No audiobook is playing. Start it in your player app.',
101     });
102     expect(playerProblem('no_player', false)).toEqual({ text: 'Load the audio file first.' });
103     expect(playerProblem('no_overlay', true)).toEqual({
104       text: 'SubRead Overlay is not installed.',
105       link: { href: OVERLAY_RELEASES, text: 'Get it' },
106     });
107   });
108 
109   it('gives a button that opens SubRead Overlay when it has no notification access', () => {
110     // The phone finding: the strip showed "PlayerError: no_notification_access".
111     expect(playerProblem('no_notification_access', true)).toEqual({
112       text: 'Allow notification access in SubRead Overlay.',
113       action: { id: 'open-overlay', text: 'Open SubRead Overlay' },
114     });
115   });
116 
117   it('gives the button, not the link, when SubRead Overlay is installed but does not answer', () => {
118     expect(playerProblem('no_overlay', true, true)).toEqual({
119       text: 'SubRead Overlay does not answer.',
120       action: { id: 'open-overlay', text: 'Open SubRead Overlay' },
121     });
122     expect(playerMessage(state({ positionMs: null, error: 'no_overlay' }), true, true)).toEqual(
123       playerProblem('no_overlay', true, true),
124     );
125     expect(errorMessage(new PlayerError('no_overlay'), true, true)).toEqual(
126       playerProblem('no_overlay', true, true),
127     );
128   });
129 
130   it('gives null for another reason', () => {
131     expect(playerProblem('malformed', true)).toBeNull();
132   });
133 });
134 
135 describe('shownInPlayerPart', () => {
136   it('does not repeat the problem that the player part shows', () => {
137     // The phone finding: after play, the strip said the same sentence twice.
138     expect(
139       shownInPlayerPart(new PlayerError('no_notification_access'), 'no_notification_access'),
140     ).toBe(true);
141     expect(shownInPlayerPart(new PlayerError('no_player'), 'no_player')).toBe(true);
142   });
143 
144   it('tells another error, or a problem that the player part does not show', () => {
145     expect(shownInPlayerPart(new PlayerError('no_player'), null)).toBe(false);
146     expect(shownInPlayerPart(new PlayerError('no_player'), undefined)).toBe(false);
147     expect(shownInPlayerPart(new PlayerError('no_overlay'), 'no_player')).toBe(false);
148     expect(shownInPlayerPart(new Error('no_player'), 'no_player')).toBe(false);
149   });
150 });
151 
152 describe('playerCommandsOff', () => {
153   it('turns off play and "audio to page" without notification access', () => {
154     // The phone finding: with no notification access, play stayed on.
155     expect(playerCommandsOff('no_notification_access', true)).toBe(true);
156     expect(playerCommandsOff('no_overlay', true)).toBe(true);
157   });
158 
159   it('turns them off on the web without an audio file only', () => {
160     expect(playerCommandsOff('no_player', false)).toBe(true);
161     expect(playerCommandsOff('no_player', true)).toBe(false);
162     expect(playerCommandsOff(null, true)).toBe(false);
163     expect(playerCommandsOff(null, false)).toBe(false);
164   });
165 });
166 
167 describe('errorMessage', () => {
168   it('gives the text of the reason for a PlayerError', () => {
169     expect(errorMessage(new PlayerError('no_player'), false)).toEqual({
170       text: 'Load the audio file first.',
171     });
172     expect(errorMessage(new PlayerError('empty'), true)).toEqual({
173       text: 'The player failed: empty.',
174     });
175   });
176 
177   it('gives the error as text for another error', () => {
178     expect(errorMessage(new Error('boom'), true)).toEqual({ text: 'Error: boom' });
179   });
180 });
181 
182 describe('playerMessage', () => {
183   it('gives the time and the play state', () => {
184     expect(playerMessage(state({ positionMs: 75_000, playing: true }), true)).toEqual({
185       text: 'Player 01:15, playing',
186     });
187     expect(playerMessage(state({ positionMs: 5_000 }), false)).toEqual({
188       text: 'Audio 00:05, paused',
189     });
190   });
191 
192   it('gives the problem and what to do', () => {
193     expect(playerMessage(state({ positionMs: null, error: 'no_player' }), false)).toEqual({
194       text: 'Load the audio file first.',
195     });
196     expect(playerMessage(state({ error: 'malformed' }), true)).toEqual({
197       text: 'Player: cannot read it (malformed).',
198     });
199     expect(playerMessage(state({ positionMs: null }), true)).toEqual({
200       text: 'Player: no position.',
201     });
202     expect(playerMessage(null, true)).toEqual({ text: 'Player: not read yet.' });
203   });
204 });
205 
206 describe('page texts', () => {
207   it('uses the singular for one page', () => {
208     expect(pdfLoadedText(1)).toBe('PDF loaded: 1 page.');
209     expect(pdfLoadedText(40)).toBe('PDF loaded: 40 pages.');
210     expect(pagesReadText({ text: 0, ocr: 1 })).toBe('1 page read: 0 text layer, 1 OCR.');
211     expect(pagesReadText({ text: 38, ocr: 2 })).toBe('40 pages read: 38 text layer, 2 OCR.');
212   });
213 
214   it('tells the reading progress and the page that is not read yet', () => {
215     expect(readingText(3, 40, '')).toBe('Reading 3/40');
216     expect(readingText(3, 40, 'page 5: OCR 45%')).toBe('Reading 3/40 · page 5: OCR 45%');
217     expect(notReadText(3, 40)).toBe('This page is not read yet (3/40 read).');
218   });
219 });
220 
221 /** The error of pdf.js 6 for a file that is not a PDF: a PNG, an .srt, or a cut PDF. */
222 function invalidPdf(): Error {
223   const err = new Error('Invalid PDF structure.');
224   err.name = 'InvalidPDFException';
225   return err;
226 }
227 
228 describe('file texts', () => {
229   it('says that a file is not a PDF, with no exception text', () => {
230     expect(pdfOpenText(invalidPdf())).toBe('This file is not a PDF, or it is damaged.');
231   });
232 
233   it('gives another error of pdf.js as it is', () => {
234     expect(pdfOpenText(new Error('Worker was destroyed'))).toBe(
235       'Cannot open the PDF: Error: Worker was destroyed',
236     );
237   });
238 
239   it('names the subtitle file with no cues', () => {
240     expect(noCuesText('sample-eng.pdf')).toBe(
241       'No subtitle lines in sample-eng.pdf. Choose an .srt or .vtt file.',
242     );
243   });
244 });
245 
246 describe('the end of the reading', () => {
247   const retry = { id: 'retry-reading', text: 'Retry' };
248 
249   it('tells that OCR could not start, with Retry', () => {
250     expect(readingEndMessage(40, true)).toEqual({ text: OCR_START_TEXT, action: retry });
251     expect(OCR_START_TEXT).toBe(
252       'OCR could not start. The first OCR run needs a network connection to download the language data.',
253     );
254   });
255 
256   it('counts the pages that could not be read, with Retry', () => {
257     expect(readingEndMessage(1, false)).toEqual({
258       text: '1 page could not be read.',
259       action: retry,
260     });
261     expect(readingEndMessage(3, false)).toEqual({
262       text: '3 pages could not be read.',
263       action: retry,
264     });
265     expect(readingEndMessage(0, false)).toBeNull();
266   });
267 
268   it('adds the Force OCR note while it is on', () => {
269     expect(withForceOcr(null, false)).toBeNull();
270     expect(withForceOcr(null, true)).toEqual({ text: 'Force OCR is on.' });
271     expect(withForceOcr({ text: 'Reading 1/2 · page 2: OCR 45%' }, true)).toEqual({
272       text: 'Reading 1/2 · page 2: OCR 45% · Force OCR is on',
273     });
274     expect(withForceOcr(readingEndMessage(2, false), true)).toEqual({
275       text: '2 pages could not be read. Force OCR is on.',
276       action: retry,
277     });
278     expect(withForceOcr({ text: 'Reading 1/2' }, false)).toEqual({ text: 'Reading 1/2' });
279   });
280 });
281 
282 describe('ankiText', () => {
283   it('tells the result of SubRead Anki', () => {
284     expect(ankiText({ added: true })).toEqual({ text: 'Card added to Anki.' });
285     expect(ankiText({ error: 'not_installed' })).toEqual({
286       text: 'SubRead Anki is not installed.',
287       link: { href: ANKI_RELEASES, text: 'Get SubRead Anki' },
288     });
289     expect(ankiText({ error: 'cannot_start' })).toEqual({ text: 'SubRead Anki could not start.' });
290   });
291 
292   it('says nothing when the user closed the card', () => {
293     expect(ankiText({ added: false })).toBeNull();
294   });
295 
296   it('tells the web that cards need Android', () => {
297     expect(ANKI_WEB_TEXT).toBe('Anki cards need SubRead Anki on Android.');
298   });
299 });
300 
301 describe('SubRead texts', () => {
302   it('tells how far the reading is while SubRead waits', () => {
303     expect(waitPagesText(3, 40)).toBe('Reading pages 3/40 first...');
304   });
305 
306   it('tells what SubRead made', () => {
307     expect(subreadResultText({ cues: 15, language: 'en', matchRate: 0.97 })).toBe(
308       '15 cues, language en, 97% of the lines found in the book.',
309     );
310     expect(subreadResultText({ cues: 15, language: 'en', matchRate: 0.5 })).toBe(
311       '15 cues, language en, 50% of the lines found in the book. Under 80% usually means another edition or the wrong language.',
312     );
313     // The result file holds only the .srt, and the plugin gives -1 for no value.
314     expect(subreadResultText({})).toBe('? cues, language ?.');
315     expect(subreadResultText({ cues: -1, language: null, matchRate: -1 })).toBe(
316       '? cues, language ?.',
317     );
318   });
319 
320   it('tells that the result for a book that is not open waits for that book', () => {
321     expect(keptResultText('neko.pdf')).toBe(
322       'SubRead made the subtitles of neko.pdf. The reader offers them when you open that book.',
323     );
324   });
325 
326   it('asks the user to choose the audio again when SubRead cannot start', () => {
327     expect(subreadErrorText('cannot_start: Permission Denial: opening provider')).toBe(
328       'SubRead cannot read the audio file. Choose the audio again.',
329     );
330     expect(subreadErrorText('cancelled')).toBe('SubRead made no subtitles: cancelled.');
331   });
332 
333   it('asks with the reasons before the subtitles of SubRead load', () => {
334     expect(subreadQuestion(['A.', 'B.'])).toBe(
335       'SubRead made subtitles for this book.\n\nA.\n\nB.\n\nLoad them?',
336     );
337   });
338 });
339 
340 describe('matchedText', () => {
341   it('counts the matched cues of the pages read so far', () => {
342     expect(matchedText(2, 15, false)).toBe('2/15 cues matched to the pages read so far.');
343   });
344 
345   it('adds the hint when all pages are read and less than 30% of the cues match', () => {
346     // sample-jpn.srt on the English PDF.
347     expect(matchedText(0, 3, true)).toBe(`0/3 cues matched. ${MISMATCH_TEXT}`);
348     expect(matchedText(4, 15, true)).toBe(`4/15 cues matched. ${MISMATCH_TEXT}`);
349     expect(matchedText(5, 15, true)).toBe('5/15 cues matched.');
350     expect(matchedText(15, 15, true)).toBe('15/15 cues matched.');
351   });
352 
353   it('gives the hint exactly for all pages read and less than 30% matched', () => {
354     expect(MISMATCH_SHARE).toBe(0.3);
355     // 3 of 10 is 30%: no hint. In floating point 0.3 * 10 is more than 3.
356     expect(matchedText(3, 10, true)).toBe('3/10 cues matched.');
357     const counts = fc
358       .tuple(fc.nat({ max: 500 }), fc.nat({ max: 500 }))
359       .map(([a, b]) => ({ matched: Math.min(a, b), cues: Math.max(a, b) }));
360     fc.assert(
361       fc.property(counts, fc.boolean(), ({ matched, cues }, allRead) => {
362         const hint = matchedText(matched, cues, allRead).endsWith(MISMATCH_TEXT);
363         // Whole numbers: 10 * matched < 3 * cues is exact.
364         expect(hint).toBe(allRead && 10 * matched < 3 * cues);
365       }),
366     );
367   });
368 });