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 });