commit 34f8e48386eb68b37ffb26ea44c14a9ecbe839ff truex <truex@equwal.com> 2026-09-26 18:49:55 -0700 Make subtitles with SubRead after all pages are read - SubRead started with the pages read so far, so the subtitles covered part of the book. The job now waits: the menu says "Reading pages N/M first..." and SubRead starts when the last page is read. Another book cancels the job that waits. One job runs at a time. - makeSubtitles gets a result file per book: resultName gives "subread-<hash>.srt" from the FNV-1a hash of the book key, because the plugin keeps only [A-Za-z0-9._-] and a Japanese name becomes "_". After the answer, when a book opens and when the app is active again, pendingSubtitles loads an .srt that SubRead 0.10 and later wrote, also when Android stopped the reader during the job. A result for a book that is not open goes to the meta data of that book. - An error that starts with "cannot_start" gives "SubRead cannot read the audio file. Choose the audio again." - SubRead 0.9.1 on an audio file with no speech gave 23 cues cut in the middle of words, the language "km" and a match rate of 1, and the result replaced the subtitles that the user loaded. subreadConcerns now asks with confirm() before a result replaces a file of the user, when the language is not the requested one (with "auto": the language of the OCR setting), and when the match rate is under 0.8.
src/book-subtitles.ts | 68 ++++++++++++++ src/main.ts | 223 +++++++++++++++++++++++++++++++++----------- src/messages.ts | 41 ++++++++ src/token-cache.ts | 9 +- test/book-subtitles.test.ts | 87 ++++++++++++++++- test/messages.test.ts | 37 ++++++++ test/token-cache.test.ts | 9 ++ 7 files changed, 415 insertions(+), 59 deletions(-)
diff --git a/src/book-subtitles.ts b/src/book-subtitles.ts index 906f12d..5db1729 100644 --- a/src/book-subtitles.ts +++ b/src/book-subtitles.ts @@ -32,3 +32,71 @@ export function subtitlesOnOpen( if (loaded === null || loaded === book) return 'keep'; return saved ? 'load' : 'clear'; } + +/** + * The name of the result file of SubRead for the book `book` (see bookKey), + * for example "subread-1a2b3c4d.srt". The plugin keeps only A-Z, a-z, 0-9, + * ".", "_" and "-" of a name, so a Japanese file name gives only "_". The + * FNV-1a hash of the key tells two books apart. + */ +export function resultName(book: string): string { + let hash = 0x811c9dc5; + for (const byte of new TextEncoder().encode(book)) { + hash = Math.imul(hash ^ byte, 0x01000193) >>> 0; + } + return `subread-${hash.toString(16).padStart(8, '0')}.srt`; +} + +/** A SubRead result is suspect when it found less than this share of the lines in the book. */ +export const LOW_MATCH_RATE = 0.8; + +/** What the reader knows when SubRead gives subtitles for the open book. */ +export interface SubreadCheck { + /** The narration language that the user asked SubRead for: "auto", "ja", "en". */ + requested: string; + /** The OCR language of the book: "eng", "jpn", "jpn_vert", "jpn+eng". */ + ocrLang: string; + /** The language that SubRead found, when it tells. */ + language?: string | null; + /** 0 to 1: the share of the lines that SubRead found in the book, when it tells. */ + matchRate?: number; + /** The subtitles that are loaded now, if any. */ + loaded?: { name: string; source: 'file' | 'subread' } | null; +} + +/** The languages that the narration can have: the requested one, else those of the OCR language. */ +function expectedLanguages(requested: string, ocrLang: string): string[] { + if (requested !== 'auto') return [requested]; + const out: string[] = []; + if (ocrLang.includes('jpn')) out.push('ja'); + if (ocrLang.includes('eng')) out.push('en'); + return out; +} + +/** + * The reasons to ask the user before the subtitles of SubRead load. Empty + * when they can load at once. + * + * SubRead 0.9.1 gave 23 cues cut in the middle of words, the language "km" + * and a match rate of 1 for an audio file with no speech, and the result + * replaced the subtitles that the user loaded. So the reader asks when + * subtitles from a file are loaded, when the language is not the one of + * the book, and when the match rate is low. With the language "auto", the + * OCR language tells the language of the book. + */ +export function subreadConcerns(c: SubreadCheck): string[] { + const out: string[] = []; + if (c.loaded?.source === 'file') { + out.push(`They replace ${c.loaded.name}, the subtitles that you loaded.`); + } + const want = expectedLanguages(c.requested, c.ocrLang); + // "en-US" and "ja_JP" count as "en" and "ja". + const found = c.language?.trim().toLowerCase().split(/[-_]/)[0]; + if (found && want.length > 0 && !want.includes(found)) { + out.push(`SubRead found the language "${c.language}", not "${want.join('" or "')}".`); + } + if (c.matchRate !== undefined && c.matchRate >= 0 && c.matchRate < LOW_MATCH_RATE) { + out.push(`SubRead found only ${Math.round(c.matchRate * 100)}% of the lines in the book.`); + } + return out; +} diff --git a/src/main.ts b/src/main.ts index fc21eda..38b71eb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ // (the strip) and view.ts (the page). import { createAligner, type Aligner, type OcrToken, type TokenSpan } from './align'; -import { srtName, subtitlesOnOpen } from './book-subtitles'; +import { resultName, srtName, subreadConcerns, subtitlesOnOpen } from './book-subtitles'; import { bookText } from './book-text'; import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from './clock-source'; import { audioPage, cueParts, cueProgress, pageStartTime, type CueParts } from './cue-pages'; @@ -30,7 +30,12 @@ import { playerMessage, readingEndMessage, readingText, + SUBREAD_NOT_INSTALLED, + subreadErrorText, + subreadQuestion, + subreadResultText, UPDATING_CACHE_TEXT, + waitPagesText, withForceOcr, type Message, } from './messages'; @@ -41,11 +46,12 @@ import { defaultRtl } from './paging'; import type { PdfDoc } from './pdf'; import { nextPage } from './read-order'; import { scrollTarget } from './scroll'; -import { onAction, say, showPlayer, showReading } from './status'; -import { isAndroid, SubRead } from './subread'; +import { fill, onAction, say, showPlayer, showReading } from './status'; +import { isAndroid, SubRead, type SubtitlesResult } from './subread'; import { decodeSubtitles, lastCueAt, parseSubtitles, type Cue } from './subtitles'; import { bookKey, + bookName, clearLastBook, clearPages, dbReady, @@ -74,8 +80,6 @@ const PAGE_SAVE_MS = 1000; /** After this many milliseconds, the strip tells that the database upgrade holds the reading. */ const SLOW_DB_MS = 2000; -const SUBREAD_RELEASES = 'https://github.com/equwal/subread-android/releases/latest'; - function el<T extends HTMLElement>(id: string): T { const e = document.getElementById(id); if (!e) throw new Error(`Missing element #${id}`); @@ -158,6 +162,10 @@ const state = { dbSlow: false, /** "Force OCR" of the open book: the reading skips the text layer. */ forceOcr: false, + /** A SubRead job that waits until all pages of its book are read. */ + makeWait: null as { audio: { uri: string; name: string }; book: string } | null, + /** The book of the SubRead job that runs, or null. */ + making: null as string | null, /** True while the player waits for a dictionary lookup that this app paused. */ lookupPaused: false, /** True while a page turn by the user holds the follow. */ @@ -333,10 +341,16 @@ async function openBook(file: File, meta?: BookMeta): Promise<boolean> { clearSubtitles(); } const subtitlesSeq = state.subtitlesSeq; + // A SubRead job that waits for the pages of another book does not start. + if (state.makeWait && state.makeWait.book !== key) { + state.makeWait = null; + ui.makeStatus.textContent = ''; + } setForceOcr(meta?.forceOcr ?? false); ui.forceOcr.disabled = false; ui.clearBook.disabled = false; - ui.makeSubs.disabled = false; + // One SubRead job at a time. + ui.makeSubs.disabled = state.making !== null; ui.empty.hidden = true; ui.page.hidden = false; if (state.clock) showPlayerStatus(state.clock); @@ -382,22 +396,26 @@ function applyMeta(meta: BookMeta, attempt: number, subtitlesSeq: number): void setForceOcr(forceOcr); void readBook(); } - if (state.subtitlesSeq !== subtitlesSeq) return; - const loaded = state.subtitles ? state.subtitlesBook : undefined; - switch (subtitlesOnOpen(loaded, key, meta.subtitles !== undefined)) { - case 'keep': - if (state.subtitles) { - state.subtitlesBook = key; - void putMeta(key, { subtitles: state.subtitles }); - } - break; - case 'load': - if (meta.subtitles) loadSubtitles(meta.subtitles, { save: false }); - break; - case 'clear': - clearSubtitles(); - break; + if (state.subtitlesSeq === subtitlesSeq) { + const loaded = state.subtitles ? state.subtitlesBook : undefined; + switch (subtitlesOnOpen(loaded, key, meta.subtitles !== undefined)) { + case 'keep': + if (state.subtitles) { + state.subtitlesBook = key; + void putMeta(key, { subtitles: state.subtitles }); + } + break; + case 'load': + if (meta.subtitles) loadSubtitles(meta.subtitles, { save: false }); + break; + case 'clear': + clearSubtitles(); + break; + } } + // After the saved subtitles: a SubRead result for a book with a file of + // the user asks first. + void checkPendingSubtitles(); } // --- The page of the book, in its meta data --- @@ -601,6 +619,7 @@ async function readBook(retry = false): Promise<void> { setReading(readingText(read(), total, `page ${page + 1}: ${how}`)); if (page === state.currentPage) view.mark(); scheduleAlign(); + startMakeWhenRead(); } const matched = align(); say( @@ -758,52 +777,144 @@ function showSrtRow(): void { }; } +// SubRead makes the subtitles from the audio and the text of the book. It +// starts when all pages are read: with part of the text, the subtitles +// cover only part of the book. ui.makeSubs.addEventListener('click', async () => { - const file = state.file; - if (!file) return; - let audio; + const book = state.book; + if (book === null) return; + let audio: { uri: string; name: string }; try { audio = await SubRead.pickAudio(); } catch { return; } - const read = state.pages.filter((p) => p).length; - ui.makeStatus.textContent = allPagesRead() - ? `SubRead makes the subtitles for ${audio.name}...` - : `Only ${read} of ${state.pdf?.numPages ?? 0} pages are read. The subtitles cover those pages. SubRead runs...`; - const result = await SubRead.makeSubtitles({ - audio: audio.uri, - bookText: bookText(state.tokens), - language: ui.subLang.value, - }); - // The subtitles are for the book of the job, not for a book that opened since. - if (state.file !== file) return; + if (state.book !== book) return; + state.makeWait = { audio, book }; + startMakeWhenRead(); +}); + +/** + * Starts the SubRead job that waits, when all pages of its book are read. + * Else tells how far the reading is. readBook calls this after each page. + */ +function startMakeWhenRead(): void { + const wait = state.makeWait; + const pdf = state.pdf; + if (!wait || !pdf || wait.book !== state.book) return; + if (!allPagesRead()) { + ui.makeStatus.textContent = waitPagesText(state.pages.filter((p) => p).length, pdf.numPages); + return; + } + state.makeWait = null; + void makeSubtitles(wait.audio, wait.book); +} + +/** Asks SubRead for the subtitles of the book `book`, and loads them after the checks. */ +async function makeSubtitles(audio: { uri: string; name: string }, book: string): Promise<void> { + const name = resultName(book); + state.making = book; + ui.makeSubs.disabled = true; + ui.makeStatus.textContent = `SubRead makes the subtitles for ${audio.name}...`; + let result: SubtitlesResult; + try { + result = await SubRead.makeSubtitles({ + audio: audio.uri, + bookText: bookText(state.tokens), + language: ui.subLang.value, + resultName: name, + }); + } catch (err) { + result = { error: String(err) }; + } finally { + state.making = null; + ui.makeSubs.disabled = state.book === null; + } + // The result file waits for its book: pendingSubtitles loads it when the book opens. + if (state.book !== book) { + ui.makeStatus.textContent = 'SubRead finished, but another book is open now.'; + return; + } if (result.error === 'not_installed') { - ui.makeStatus.replaceChildren('SubRead is not installed. '); - const a = document.createElement('a'); - a.href = SUBREAD_RELEASES; - a.target = '_blank'; - a.rel = 'noopener'; - a.textContent = 'Get SubRead'; - ui.makeStatus.append(a); + fill(ui.makeStatus, SUBREAD_NOT_INSTALLED); return; } - if (result.error !== undefined || result.srt === undefined) { - ui.makeStatus.textContent = `SubRead made no subtitles: ${result.error ?? 'no file'}.`; + // SubRead 0.10 and later also write the .srt into the result file. Read + // it now, so that the file goes, and use it when the answer has no .srt. + const copy = await SubRead.pendingSubtitles({ resultName: name }).catch(() => ({}) as PendingSrt); + const srt = result.srt ?? copy.srt; + if (srt === undefined) { + ui.makeStatus.textContent = subreadErrorText(result.error ?? 'no file'); return; } - const rate = result.matchRate !== undefined && result.matchRate >= 0 ? result.matchRate : null; - ui.makeStatus.textContent = - `${result.cues ?? '?'} cues, language ${result.language ?? '?'}` + - (rate === null ? '.' : `, ${Math.round(rate * 100)}% of the lines found in the book.`) + - (rate !== null && rate < 0.8 - ? ' Under 80% usually means another edition or the wrong language.' - : ''); - loadSubtitles( - { name: srtName(file.name), text: result.srt, source: 'subread' }, - { from: 'SubRead' }, - ); -}); + if (state.book !== book) { + void putMeta(book, { subtitles: subreadSubtitles(book, srt) }); + return; + } + ui.makeStatus.textContent = subreadResultText(result); + offerSubreadSubtitles(srt, result); +} + +type PendingSrt = { srt?: string }; + +/** Subtitles that SubRead made for the book `book`. */ +function subreadSubtitles(book: string, srt: string): BookSubtitles { + return { name: srtName(bookName(book)), text: srt, source: 'subread' }; +} + +/** + * Loads subtitles that SubRead made for the open book. The user confirms + * first when they replace a file that the user loaded, or when the + * language or the match rate of SubRead look wrong (subreadConcerns). + */ +function offerSubreadSubtitles( + srt: string, + info: { language?: string | null; matchRate?: number }, +): boolean { + const book = state.book; + if (book === null) return false; + const concerns = subreadConcerns({ + requested: ui.subLang.value, + ocrLang: ui.lang.value, + language: info.language, + matchRate: info.matchRate, + loaded: state.subtitles, + }); + if (concerns.length > 0 && !confirm(subreadQuestion(concerns))) { + say('The subtitles of SubRead are not loaded.'); + return false; + } + loadSubtitles(subreadSubtitles(book, srt), { from: 'SubRead' }); + return true; +} + +/** + * Loads the .srt that SubRead wrote into the result file of the open book. + * SubRead 0.10 and later write it also when Android stopped the reader + * during the job; then no answer comes. The reader checks when a book + * opens and when the app is active again. + */ +async function checkPendingSubtitles(): Promise<void> { + const book = state.book; + // During a job the answer of makeSubtitles brings the .srt. + if (!isAndroid || book === null || state.making !== null) return; + let pending: PendingSrt; + try { + pending = await SubRead.pendingSubtitles({ resultName: resultName(book) }); + } catch { + return; + } + const srt = pending.srt; + if (srt === undefined) return; + // The call removed the file, so the .srt goes to the meta data of its book. + if (state.book !== book) { + void putMeta(book, { subtitles: subreadSubtitles(book, srt) }); + return; + } + if (offerSubreadSubtitles(srt, {})) { + ui.makeStatus.textContent = 'SubRead finished while the reader was closed.'; + } +} // --- Alignment --- diff --git a/src/messages.ts b/src/messages.ts index 732d003..60a4a91 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -13,6 +13,7 @@ export interface Message { } export const OVERLAY_RELEASES = 'https://github.com/equwal/subread-overlay/releases/latest'; +export const SUBREAD_RELEASES = 'https://github.com/equwal/subread-android/releases/latest'; /** "1 page", "2 pages". */ function count(n: number, noun: string): string { @@ -113,6 +114,46 @@ export function withForceOcr(m: Message | null, forceOcr: boolean): Message | nu return { ...m, text }; } +export const SUBREAD_NOT_INSTALLED: Message = { + text: 'SubRead is not installed.', + link: { href: SUBREAD_RELEASES, text: 'Get SubRead' }, +}; + +/** SubRead waits for the reading: it makes the subtitles from the text of all pages. */ +export function waitPagesText(read: number, total: number): string { + return `Reading pages ${read}/${total} first...`; +} + +/** What SubRead made: the cues, the language and the share of the lines found in the book. */ +export function subreadResultText(r: { + cues?: number; + language?: string | null; + matchRate?: number; +}): string { + const cues = r.cues !== undefined && r.cues >= 0 ? String(r.cues) : '?'; + const rate = r.matchRate !== undefined && r.matchRate >= 0 ? r.matchRate : null; + return ( + `${cues} cues, language ${r.language ?? '?'}` + + (rate === null ? '.' : `, ${Math.round(rate * 100)}% of the lines found in the book.`) + + (rate !== null && rate < 0.8 + ? ' Under 80% usually means another edition or the wrong language.' + : '') + ); +} + +/** Why SubRead made no subtitles. `cannot_start` means that the read grant of the audio is gone. */ +export function subreadErrorText(error: string): string { + if (error.startsWith('cannot_start')) { + return 'SubRead cannot read the audio file. Choose the audio again.'; + } + return `SubRead made no subtitles: ${error}.`; +} + +/** The question before the subtitles of SubRead load, with the reasons of subreadConcerns. */ +export function subreadQuestion(concerns: readonly string[]): string { + return ['SubRead made subtitles for this book.', ...concerns, 'Load them?'].join('\n\n'); +} + /** The question before the saved pages of one book go. */ export function clearBookQuestion(name: string): string { return `Remove the saved pages of ${name}? The reader reads them again the next time it opens the book.`; diff --git a/src/token-cache.ts b/src/token-cache.ts index 1b502a6..f34f38f 100644 --- a/src/token-cache.ts +++ b/src/token-cache.ts @@ -124,8 +124,7 @@ async function moveSrtToMeta( /** Subtitles from SubRead for the book `key`. "book.pdf|123" gives the name "book.srt". */ function subreadSubtitles(key: string, text: string): BookSubtitles { - const bar = key.lastIndexOf('|'); - return { name: srtName(bar < 0 ? key : key.slice(0, bar)), text, source: 'subread' }; + return { name: srtName(bookName(key)), text, source: 'subread' }; } /** Runs `op` on the database. Gives undefined when IndexedDB fails, and warns once. */ @@ -148,6 +147,12 @@ export function bookKey(file: FileId): string { return `${file.name}|${file.size}`; } +/** The file name in a book key: "book.pdf|123" gives "book.pdf". */ +export function bookName(key: string): string { + const bar = key.lastIndexOf('|'); + return bar < 0 ? key : key.slice(0, bar); +} + export function pageKey(file: FileId, page: number, lang: string, source: TextSource): string { return `${bookKey(file)}|${page}|${lang}|${source}`; } diff --git a/test/book-subtitles.test.ts b/test/book-subtitles.test.ts index 452ea59..394b446 100644 --- a/test/book-subtitles.test.ts +++ b/test/book-subtitles.test.ts @@ -1,5 +1,6 @@ +import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; -import { srtName, subtitlesOnOpen } from '../src/book-subtitles'; +import { resultName, srtName, subreadConcerns, subtitlesOnOpen } from '../src/book-subtitles'; describe('srtName', () => { it('puts .srt in place of .pdf', () => { @@ -39,3 +40,87 @@ describe('subtitlesOnOpen', () => { expect(subtitlesOnOpen(jpn, jpn, false)).toBe('keep'); }); }); + +describe('resultName', () => { + it('is the FNV-1a hash of the book key', () => { + // The test vectors of FNV-1a (32 bit). + expect(resultName('')).toBe('subread-811c9dc5.srt'); + expect(resultName('a')).toBe('subread-e40c292c.srt'); + expect(resultName('foobar')).toBe('subread-bf9cf968.srt'); + }); + + it('tells apart two Japanese books that the plugin would both call "_"', () => { + const neko = resultName('吾輩は猫である.pdf|204800'); + const botchan = resultName('坊っちゃん.pdf|204800'); + expect(neko).not.toBe(botchan); + expect(resultName('吾輩は猫である.pdf|204801')).not.toBe(neko); + }); + + it('gives a name that the plugin keeps as it is, the same for the same key', () => { + fc.assert( + fc.property(fc.string({ unit: 'binary' }), (key) => { + const name = resultName(key); + expect(name).toMatch(/^subread-[0-9a-f]{8}\.srt$/); + expect(resultName(key)).toBe(name); + }), + ); + }); +}); + +describe('subreadConcerns', () => { + const file = { name: 'sample-eng-2p.srt', source: 'file' as const }; + + it('asks for the phone case: no speech, language km, match rate 1, over a loaded file', () => { + expect( + subreadConcerns({ + requested: 'auto', + ocrLang: 'eng', + language: 'km', + matchRate: 1, + loaded: file, + }), + ).toEqual([ + 'They replace sample-eng-2p.srt, the subtitles that you loaded.', + 'SubRead found the language "km", not "en".', + ]); + }); + + it('loads at once a good result over no subtitles or over an earlier SubRead result', () => { + const good = { requested: 'auto', ocrLang: 'jpn', language: 'ja', matchRate: 0.97 }; + expect(subreadConcerns(good)).toEqual([]); + const earlier = { name: 'neko.srt', source: 'subread' as const }; + expect(subreadConcerns({ ...good, loaded: earlier })).toEqual([]); + }); + + it('compares the found language with the requested one', () => { + expect(subreadConcerns({ requested: 'ja', ocrLang: 'eng', language: 'ja' })).toEqual([]); + expect(subreadConcerns({ requested: 'ja', ocrLang: 'jpn', language: 'en' })).toEqual([ + 'SubRead found the language "en", not "ja".', + ]); + }); + + it('takes the language of the book from the OCR language when the request is auto', () => { + const both = { requested: 'auto', ocrLang: 'jpn+eng' }; + expect(subreadConcerns({ ...both, language: 'en' })).toEqual([]); + expect(subreadConcerns({ ...both, language: 'ja' })).toEqual([]); + expect(subreadConcerns({ ...both, language: 'km' })).toEqual([ + 'SubRead found the language "km", not "ja" or "en".', + ]); + const vertical = { requested: 'auto', ocrLang: 'jpn_vert', language: 'ja' }; + expect(subreadConcerns(vertical)).toEqual([]); + expect(subreadConcerns({ requested: 'auto', ocrLang: 'eng', language: 'en-US' })).toEqual([]); + }); + + it('does not ask about a language or a match rate that SubRead does not tell', () => { + expect(subreadConcerns({ requested: 'ja', ocrLang: 'jpn', language: null })).toEqual([]); + expect(subreadConcerns({ requested: 'ja', ocrLang: 'jpn', language: '' })).toEqual([]); + expect(subreadConcerns({ requested: 'ja', ocrLang: 'jpn', matchRate: -1 })).toEqual([]); + }); + + it('asks when less than 80% of the lines are found in the book', () => { + expect(subreadConcerns({ requested: 'en', ocrLang: 'eng', matchRate: 0.42 })).toEqual([ + 'SubRead found only 42% of the lines in the book.', + ]); + expect(subreadConcerns({ requested: 'en', ocrLang: 'eng', matchRate: 0.8 })).toEqual([]); + }); +}); diff --git a/test/messages.test.ts b/test/messages.test.ts index 415f953..b9c0410 100644 --- a/test/messages.test.ts +++ b/test/messages.test.ts @@ -18,6 +18,10 @@ import { playerProblem, readingEndMessage, readingText, + subreadErrorText, + subreadQuestion, + subreadResultText, + waitPagesText, withForceOcr, } from '../src/messages'; @@ -169,6 +173,39 @@ describe('the end of the reading', () => { }); }); +describe('SubRead texts', () => { + it('tells how far the reading is while SubRead waits', () => { + expect(waitPagesText(3, 40)).toBe('Reading pages 3/40 first...'); + }); + + it('tells what SubRead made', () => { + expect(subreadResultText({ cues: 15, language: 'en', matchRate: 0.97 })).toBe( + '15 cues, language en, 97% of the lines found in the book.', + ); + expect(subreadResultText({ cues: 15, language: 'en', matchRate: 0.5 })).toBe( + '15 cues, language en, 50% of the lines found in the book. Under 80% usually means another edition or the wrong language.', + ); + // The result file holds only the .srt, and the plugin gives -1 for no value. + expect(subreadResultText({})).toBe('? cues, language ?.'); + expect(subreadResultText({ cues: -1, language: null, matchRate: -1 })).toBe( + '? cues, language ?.', + ); + }); + + it('asks the user to choose the audio again when SubRead cannot start', () => { + expect(subreadErrorText('cannot_start: Permission Denial: opening provider')).toBe( + 'SubRead cannot read the audio file. Choose the audio again.', + ); + expect(subreadErrorText('cancelled')).toBe('SubRead made no subtitles: cancelled.'); + }); + + it('asks with the reasons before the subtitles of SubRead load', () => { + expect(subreadQuestion(['A.', 'B.'])).toBe( + 'SubRead made subtitles for this book.\n\nA.\n\nB.\n\nLoad them?', + ); + }); +}); + describe('matchedText', () => { it('counts the matched cues of the pages read so far', () => { expect(matchedText(2, 15, false)).toBe('2/15 cues matched to the pages read so far.'); diff --git a/test/token-cache.test.ts b/test/token-cache.test.ts index 716cc97..3f8e61a 100644 --- a/test/token-cache.test.ts +++ b/test/token-cache.test.ts @@ -206,6 +206,15 @@ describe('token cache', () => { ); }, 30_000); + it('gives back the file name of a book key, also a name with "|"', async () => { + const cache = await freshCache(); + fc.assert( + fc.property(fc.string({ unit: 'binary' }), fc.nat(), (name, size) => { + expect(cache.bookName(cache.bookKey({ name, size }))).toBe(name); + }), + ); + }); + it('works without IndexedDB, and warns once', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); vi.spyOn(indexedDB, 'open').mockImplementation(() => {