commit e25c0880517ba0bcf4c11c13de6d6c6325fd1d00 truex <truex@equwal.com> 2026-09-26 14:12:23 -0700 Show the status in a strip under the top bar The player status, the reading of the pages and the messages were in the drawer, so a phone with a closed drawer showed none of them. A slim strip under the top bar now shows three parts: the player (the time and the play state, or the problem and what to do), the reading of the pages with the OCR progress of the page, and the last event, which fades after 6 seconds. Only the event part is a live region. A PlayerError now gives a text that tells what to do. A seek from the cue list catches its error. "PDF loaded" uses the singular for one page. The texts are in the pure module messages.ts.
index.html | 11 ++-- src/main.ts | 135 ++++++++++++++++++++++---------------------------- src/messages.ts | 86 ++++++++++++++++++++++++++++++++ src/status.ts | 68 +++++++++++++++++++++++++ src/style.css | 62 +++++++++++++++++------ test/messages.test.ts | 100 +++++++++++++++++++++++++++++++++++++ 6 files changed, 365 insertions(+), 97 deletions(-)
diff --git a/index.html b/index.html index 783f041..a60f7f1 100644 --- a/index.html +++ b/index.html @@ -18,6 +18,12 @@ </button> </header> + <div id="strip"> + <span id="player-status"></span> + <span id="pages-status" hidden></span> + <span id="status" aria-live="polite"></span> + </div> + <aside id="panel"> <h1>Scan Subread</h1> <p class="hint"> @@ -52,11 +58,6 @@ <audio id="audio" controls></audio> </section> - <div id="player-status" class="hint">Player: not read yet.</div> - <div id="pages-status" class="hint"></div> - <div id="status" aria-live="polite"></div> - <progress id="progress" max="1" value="0" hidden></progress> - <fieldset id="follow"> <legend>Follow the audio</legend> <label diff --git a/src/main.ts b/src/main.ts index 2bfe1d8..a0b27df 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,9 +8,18 @@ import { LocalAudioClock, OverlayClock, type ClockSource, type ClockState } from import { cueMoved, follow, type FollowMode } from './follower'; import { lookupText, tokenAt } from './hit-test'; import { lineBoxes } from './line-boxes'; +import { + clockText, + errorMessage, + pagesReadText, + pdfLoadedText, + playerMessage, + readingText, +} from './messages'; import { createOcr, type Ocr } from './ocr'; import { loadPdf, type PdfDoc } from './pdf'; import { nextPage } from './read-order'; +import { say, showPlayer, showReading } from './status'; import { isAndroid, SubRead } from './subread'; import { cueIndexAt, parseSubtitles, type Cue } from './subtitles'; import { @@ -36,7 +45,6 @@ const BOX_PAD = 2; /** Milliseconds to wait after a page is read before the cues are aligned again. */ const ALIGN_DEBOUNCE = 300; -const OVERLAY_RELEASES = 'https://github.com/equwal/subread-overlay/releases/latest'; const SUBREAD_RELEASES = 'https://github.com/equwal/subread-android/releases/latest'; function el<T extends HTMLElement>(id: string): T { @@ -60,10 +68,6 @@ const ui = { audioRow: el<HTMLElement>('audio-row'), audioFile: el<HTMLInputElement>('audio-file'), audio: el<HTMLAudioElement>('audio'), - playerStatus: el<HTMLDivElement>('player-status'), - pagesStatus: el<HTMLDivElement>('pages-status'), - status: el<HTMLDivElement>('status'), - progress: el<HTMLProgressElement>('progress'), follow: el<HTMLFieldSetElement>('follow'), lang: el<HTMLSelectElement>('lang'), forceOcr: el<HTMLInputElement>('force-ocr'), @@ -154,16 +158,9 @@ ui.forceOcr.addEventListener('change', () => { void readBook(); }); -function setStatus(text: string, link?: { href: string; text: string }): void { - ui.status.textContent = text; - if (link) { - const a = document.createElement('a'); - a.href = link.href; - a.target = '_blank'; - a.rel = 'noopener'; - a.textContent = link.text; - ui.status.append(' ', a); - } +/** Shows a command that failed in the strip. */ +function sayError(err: unknown): void { + say(errorMessage(err, isAndroid)); } // --- Drawer --- @@ -204,13 +201,13 @@ ui.pdfFile.addEventListener('change', async () => { state.activeCue = -1; state.markedCue = -1; ui.makeSubs.disabled = false; - setStatus(`PDF loaded: ${state.pdf.numPages} pages.`); + say(pdfLoadedText(state.pdf.numPages)); await showPage(0); const saved = await getSrt(bookKey(file)); if (saved) loadSubtitles(saved, 'the last SubRead run'); void readBook(); } catch (err) { - setStatus(`Cannot open PDF: ${String(err)}`); + say(`Cannot open PDF: ${String(err)}`); } }); @@ -259,24 +256,27 @@ async function readBook(): Promise<void> { state.spans = []; state.cuePages = []; state.markedCue = -1; - ui.pagesStatus.textContent = `Pages: 0/${pdf.numPages} read.`; - const pending = new Set(Array.from({ length: pdf.numPages }, (_, i) => i)); + const total = pdf.numPages; + const pending = new Set(Array.from({ length: total }, (_, i) => i)); + const counts = { text: 0, ocr: 0 }; + const read = (): number => counts.text + counts.ocr; + /** The page that is read now. */ + let page = -1; // The OCR worker starts on the first page that needs it. const ocr: { started?: Promise<Ocr> } = {}; const getOcr = (): Promise<Ocr> => { ocr.started ??= createOcr(ui.lang.value, (p) => { - ui.progress.value = p; + if (seq === state.readSeq) { + showReading(readingText(read(), total, `page ${page + 1}: OCR ${Math.round(p * 100)}%`)); + } }); return ocr.started; }; - const counts = { text: 0, ocr: 0 }; try { while (pending.size > 0) { - const page = nextPage(pending, state.currentPage); + page = nextPage(pending, state.currentPage); pending.delete(page); - ui.progress.hidden = false; - ui.progress.value = 0; - setStatus(`Reading page ${page + 1} of ${pdf.numPages}...`); + showReading(readingText(read(), total, `page ${page + 1}`)); const entry = await readPage(pdf, file, page, getOcr); if (seq !== state.readSeq) return; // Another book or another setting took over. counts[entry.source]++; @@ -284,18 +284,21 @@ async function readBook(): Promise<void> { // Put the tokens of the page at their place in page order. const at = state.pages.slice(0, page).reduce((n, p) => n + (p?.tokens.length ?? 0), 0); state.tokens.splice(at, 0, ...entry.tokens); - setStatus(`Page ${page + 1}: ${entry.source === 'text' ? 'text layer' : 'OCR'}.`); - ui.pagesStatus.textContent = - `Pages: ${counts.text + counts.ocr}/${pdf.numPages} read ` + - `(${counts.text} text layer, ${counts.ocr} OCR).`; + const how = entry.source === 'text' ? 'text layer' : 'OCR'; + showReading(readingText(read(), total, `page ${page + 1}: ${how}`)); if (page === state.currentPage) drawBoxes(); scheduleAlign(); } + const matched = align(); + say( + pagesReadText(counts) + + (matched === null ? '' : ` ${matched}/${state.cues.length} cues matched.`), + ); } catch (err) { - if (seq === state.readSeq) setStatus(`Cannot read the pages: ${String(err)}`); + if (seq === state.readSeq) say(`Cannot read the pages: ${String(err)}`); } finally { if (ocr.started) await (await ocr.started).terminate().catch(() => undefined); - if (seq === state.readSeq) ui.progress.hidden = true; + if (seq === state.readSeq) showReading(null); } } @@ -307,7 +310,7 @@ function allPagesRead(): boolean { ui.clearCache.addEventListener('click', async () => { await clearPages(); - setStatus('Page cache cleared.'); + say('Page cache cleared.'); }); // --- Subtitles --- @@ -321,10 +324,13 @@ function loadSubtitles(text: string, source: string): void { state.cuePages = []; state.activeCue = -1; state.markedCue = -1; - setStatus(`${state.cues.length} cues loaded from ${source}.`); ui.syncPage.disabled = state.cues.length === 0; renderCueList(); - align(); + const matched = align(); + say( + `${state.cues.length} cues loaded from ${source}.` + + (matched === null ? '' : ` ${matched} matched to the pages read so far.`), + ); offerSrt(text); } @@ -345,7 +351,7 @@ function offerSrt(text: string): void { ui.srtDownload.download = name; } ui.srtShare.onclick = () => { - SubRead.shareText({ name, text }).catch((err) => setStatus(String(err))); + SubRead.shareText({ name, text }).catch(sayError); }; } @@ -399,15 +405,17 @@ function scheduleAlign(): void { state.alignTimer = setTimeout(align, ALIGN_DEBOUNCE); } -/** Align the cues to the pages read so far. */ -function align(): void { +/** + * Align the cues to the pages read so far. Gives the count of matched + * cues, or null when there is nothing to align. + */ +function align(): number | null { + if (state.alignTimer) clearTimeout(state.alignTimer); state.alignTimer = null; - if (!state.aligner || state.cues.length === 0 || state.tokens.length === 0) return; + if (!state.aligner || state.cues.length === 0 || state.tokens.length === 0) return null; const before = state.cuePages; state.spans = state.aligner.spans(state.pages.map((p) => p?.tokens)); state.cuePages = state.spans.map((s) => (s.matched ? state.tokens[s.start]!.page : null)); - const matched = state.cuePages.filter((p) => p !== null).length; - setStatus(`${matched}/${state.cues.length} cues matched to the page text.`); renderCueList(); // Follow again only when the alignment moved the cue of now, for // example when it became matched. A page read in the background must not @@ -415,6 +423,7 @@ function align(): void { if (state.clock && cueMoved(before, state.cuePages, clockCue(state.clock))) { applyClock({ ...state.clock, seeked: true }); } + return state.cuePages.filter((p) => p !== null).length; } // --- Cue list --- @@ -428,7 +437,7 @@ function renderCueList(): void { const span = state.spans[i]; if (span && !span.matched) li.classList.add('unmatched'); if (i === state.activeCue) li.classList.add('active'); - li.addEventListener('click', () => void clock.seek(cue.start * 1000)); + li.addEventListener('click', () => void clock.seek(cue.start * 1000).catch(sayError)); return li; }), ); @@ -446,35 +455,10 @@ function markCueInList(i: number): void { // --- The follow --- -function clockText(ms: number): string { - const s = Math.floor(ms / 1000); - const pad = (n: number): string => String(n).padStart(2, '0'); - const h = Math.floor(s / 3600); - return `${h > 0 ? `${h}:` : ''}${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`; -} - function showPlayerStatus(s: ClockState): void { - ui.playerStatus.replaceChildren(); ui.play.disabled = s.error === 'no_overlay' || (!isAndroid && s.error === 'no_player'); ui.play.textContent = s.playing ? '⏸' : '▶'; - const name = isAndroid ? 'Player' : 'Audio'; - if (s.error === 'no_overlay') { - ui.playerStatus.append('SubRead Overlay not installed. '); - const a = document.createElement('a'); - a.href = OVERLAY_RELEASES; - a.target = '_blank'; - a.rel = 'noopener'; - a.textContent = 'Get it'; - ui.playerStatus.append(a); - } else if (s.error === 'no_notification_access') { - ui.playerStatus.textContent = 'Allow notification access in SubRead Overlay.'; - } else if (s.error === 'no_player') { - ui.playerStatus.textContent = isAndroid ? 'No player.' : 'No audio file.'; - } else if (s.positionMs === null) { - ui.playerStatus.textContent = `${name}: no position.`; - } else { - ui.playerStatus.textContent = `${name}: ${clockText(s.positionMs)}, ${s.playing ? 'playing' : 'paused'}`; - } + showPlayer(playerMessage(s, isAndroid)); } /** The cue at the position of a clock state, or -1. */ @@ -511,20 +495,17 @@ clock.start(applyClock); ui.play.addEventListener('click', () => { const action = state.clock?.playing ? clock.pause() : clock.play(); - action.catch((err) => setStatus(String(err))); + action.catch(sayError); }); ui.syncPage.addEventListener('click', () => { const i = state.cuePages.indexOf(state.currentPage); if (i < 0) { - setStatus('No cue of the subtitles is on this page.'); + say('No cue of the subtitles is on this page.'); return; } const ms = state.cues[i]!.start * 1000; - clock.seek(ms).then( - () => setStatus(`Audio moved to ${clockText(ms)}.`), - (err) => setStatus(String(err)), - ); + clock.seek(ms).then(() => say(`Audio moved to ${clockText(ms)}.`), sayError); }); ui.audioFile.addEventListener('change', () => { @@ -586,7 +567,7 @@ ui.page.addEventListener('click', async (e) => { const t = tokenAt(state.tokens, state.currentPage, x, y, TAP_TOLERANCE * size.width); if (t < 0) return; const text = lookupText(state.tokens, t); - setStatus(`Lookup: "${text}"`); + say(`Lookup: "${text}"`); const pause = ui.pauseLookup.checked && state.clock?.playing === true && !state.lookupPaused; try { if (pause) { @@ -596,7 +577,7 @@ ui.page.addEventListener('click', async (e) => { const { closed } = await SubRead.lookup({ text }); if (closed && state.lookupPaused) await clock.play(); } catch (err) { - setStatus(`Lookup failed: ${String(err)}`); + say(`Lookup failed: ${String(err)}`); } finally { if (pause) state.lookupPaused = false; } @@ -616,7 +597,7 @@ async function loadDictionaries(): Promise<void> { } ui.dict.addEventListener('change', () => { - SubRead.setDictionary({ component: ui.dict.value }).catch((err) => setStatus(String(err))); + SubRead.setDictionary({ component: ui.dict.value }).catch(sayError); }); // --- Start --- @@ -624,4 +605,4 @@ ui.dict.addEventListener('change', () => { loadSettings(); ui.make.hidden = !isAndroid; ui.audioRow.hidden = isAndroid; -if (isAndroid) void loadDictionaries().catch((err) => setStatus(String(err))); +if (isAndroid) void loadDictionaries().catch(sayError); diff --git a/src/messages.ts b/src/messages.ts new file mode 100644 index 0000000..3b68c07 --- /dev/null +++ b/src/messages.ts @@ -0,0 +1,86 @@ +// The texts of the status strip. Pure: no DOM. + +import { PlayerError, type ClockState } from './clock-source'; + +/** A text, and a link after it. */ +export interface Message { + text: string; + link?: { href: string; text: string }; +} + +export const OVERLAY_RELEASES = 'https://github.com/equwal/subread-overlay/releases/latest'; + +/** "1 page", "2 pages". */ +function count(n: number, noun: string): string { + return `${n} ${noun}${n === 1 ? '' : 's'}`; +} + +/** A position as mm:ss, or h:mm:ss from one hour. */ +export function clockText(ms: number): string { + const s = Math.floor(Math.max(0, ms) / 1000); + const pad = (n: number): string => String(n).padStart(2, '0'); + const h = Math.floor(s / 3600); + return `${h > 0 ? `${h}:` : ''}${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`; +} + +/** + * What is wrong with the player, and what to do, for a reason of + * `PlayerError` or `ClockState.error`. Null for another reason. + */ +export function playerProblem(reason: string, android: boolean): Message | null { + switch (reason) { + case 'no_player': + return { + text: android + ? 'No audiobook is playing. Start it in your player app.' + : 'Load the audio file first.', + }; + case 'no_overlay': + return { + text: 'SubRead Overlay is not installed.', + link: { href: OVERLAY_RELEASES, text: 'Get it' }, + }; + case 'no_notification_access': + return { text: 'Allow notification access in SubRead Overlay.' }; + default: + return null; + } +} + +/** The text for a command that failed. A PlayerError gets the text of its reason. */ +export function errorMessage(err: unknown, android: boolean): Message { + if (err instanceof PlayerError) { + return playerProblem(err.reason, android) ?? { text: `The player failed: ${err.reason}.` }; + } + return { text: String(err) }; +} + +/** The player part of the strip: the time and the play state, or the problem. */ +export function playerMessage(s: ClockState | null, android: boolean): Message { + const name = android ? 'Player' : 'Audio'; + if (!s) return { text: `${name}: not read yet.` }; + if (s.error !== null) { + return playerProblem(s.error, android) ?? { text: `${name}: cannot read it (${s.error}).` }; + } + if (s.positionMs === null) return { text: `${name}: no position.` }; + return { text: `${name} ${clockText(s.positionMs)}, ${s.playing ? 'playing' : 'paused'}` }; +} + +export function pdfLoadedText(pages: number): string { + return `PDF loaded: ${count(pages, 'page')}.`; +} + +/** The reading part of the strip while the pages are read. `note` tells about one page. */ +export function readingText(read: number, total: number, note: string): string { + return `Reading ${read}/${total}${note ? ` · ${note}` : ''}`; +} + +/** The message after the last page is read. */ +export function pagesReadText(counts: { text: number; ocr: number }): string { + return `${count(counts.text + counts.ocr, 'page')} read: ${counts.text} text layer, ${counts.ocr} OCR.`; +} + +/** The message for a tap on a page that is not read yet. */ +export function notReadText(read: number, total: number): string { + return `This page is not read yet (${read}/${total} read).`; +} diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..45d9b46 --- /dev/null +++ b/src/status.ts @@ -0,0 +1,68 @@ +// The status strip under the top bar. It has three parts: the player, +// the reading of the pages, and the last event. Only the event part is a +// live region, because the other parts change often. + +import type { Message } from './messages'; + +/** Milliseconds that an event message stays before it fades. */ +export const EVENT_MS = 6000; + +/** Milliseconds of the fade. The same as the transition of #status in style.css. */ +const FADE_MS = 600; + +function el(id: string): HTMLElement { + const e = document.getElementById(id); + if (!e) throw new Error(`Missing element #${id}`); + return e; +} + +const player = el('player-status'); +const reading = el('pages-status'); +const event = el('status'); + +let fadeTimer: ReturnType<typeof setTimeout> | undefined; + +/** Writes a message into `target`: the text, then the link. */ +function fill(target: HTMLElement, m: Message): void { + target.textContent = m.text; + if (m.link) { + const a = document.createElement('a'); + a.href = m.link.href; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = m.link.text; + target.append(' ', a); + } +} + +let lastPlayer = ''; + +/** Shows the state of the player. */ +export function showPlayer(m: Message): void { + // The clock sends a state four times a second. A new link under the + // finger can lose a tap, so the part changes only when the text changes. + const key = `${m.text}|${m.link?.href ?? ''}`; + if (key === lastPlayer) return; + lastPlayer = key; + fill(player, m); +} + +/** Shows how far the reading of the pages is. Null hides this part. */ +export function showReading(text: string | null): void { + reading.hidden = text === null; + reading.textContent = text ?? ''; +} + +/** Shows an event, for example a lookup or an error. It fades after EVENT_MS. */ +export function say(m: Message | string): void { + fill(event, typeof m === 'string' ? { text: m } : m); + event.classList.remove('faded'); + clearTimeout(fadeTimer); + fadeTimer = setTimeout(() => { + event.classList.add('faded'); + fadeTimer = setTimeout(() => { + event.replaceChildren(); + event.classList.remove('faded'); + }, FADE_MS); + }, EVENT_MS); +} diff --git a/src/style.css b/src/style.css index 65824ac..de7840a 100644 --- a/src/style.css +++ b/src/style.css @@ -40,6 +40,42 @@ body { white-space: nowrap; } +/* The status strip: the player, the reading of the pages, the last event. */ +#strip { + flex: none; + box-sizing: border-box; + min-height: 1.5rem; + display: flex; + flex-wrap: wrap; + align-items: baseline; + column-gap: 0.75rem; + padding: 0.15rem 0.5rem; + font-size: 0.8rem; + line-height: 1.3; + border-bottom: 1px solid #8884; + overflow-wrap: anywhere; +} + +#strip > * { + min-width: 0; +} + +#strip a { + color: inherit; +} + +#pages-status { + opacity: 0.8; +} + +#status { + transition: opacity 600ms ease-out; +} + +#status.faded { + opacity: 0; +} + #panel { position: fixed; top: var(--topbar); @@ -86,11 +122,6 @@ body.menu-open #panel { max-width: 100%; } -#status { - font-size: 0.85rem; - min-height: 1.2em; -} - #topbar button { white-space: nowrap; font-size: 0.85rem; @@ -139,16 +170,11 @@ body.menu-open #panel { font-size: 0.85rem; } -#make-status, -#player-status, -#pages-status, -#status { +#make-status { word-break: break-word; } -#status a, -#make-status a, -#player-status a { +#make-status a { color: inherit; } @@ -239,7 +265,7 @@ body.menu-open #panel { body { display: grid; grid-template-columns: var(--panel-width) 1fr; - grid-template-rows: var(--topbar) 1fr; + grid-template-rows: var(--topbar) auto 1fr; } #menu { @@ -251,9 +277,15 @@ body.menu-open #panel { grid-row: 1; } + #strip { + grid-column: 2; + grid-row: 2; + min-width: 0; + } + #panel { grid-column: 1; - grid-row: 1 / 3; + grid-row: 1 / 4; position: static; width: auto; transform: none; @@ -262,7 +294,7 @@ body.menu-open #panel { #main { grid-column: 2; - grid-row: 2; + grid-row: 3; min-width: 0; } diff --git a/test/messages.test.ts b/test/messages.test.ts new file mode 100644 index 0000000..c0138ef --- /dev/null +++ b/test/messages.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { PlayerError, type ClockState } from '../src/clock-source'; +import { + clockText, + errorMessage, + notReadText, + OVERLAY_RELEASES, + pagesReadText, + pdfLoadedText, + playerMessage, + playerProblem, + readingText, +} from '../src/messages'; + +function state(over: Partial<ClockState>): ClockState { + return { positionMs: 0, playing: false, seeked: false, error: null, ...over }; +} + +describe('clockText', () => { + it('gives minutes and seconds, and hours from one hour', () => { + expect(clockText(0)).toBe('00:00'); + expect(clockText(20_500)).toBe('00:20'); + expect(clockText(3_599_999)).toBe('59:59'); + expect(clockText(3_600_000 + 61_000)).toBe('1:01:01'); + }); +}); + +describe('playerProblem', () => { + it('tells what to do for each reason, on Android and on the web', () => { + expect(playerProblem('no_player', true)).toEqual({ + text: 'No audiobook is playing. Start it in your player app.', + }); + expect(playerProblem('no_player', false)).toEqual({ text: 'Load the audio file first.' }); + expect(playerProblem('no_overlay', true)).toEqual({ + text: 'SubRead Overlay is not installed.', + link: { href: OVERLAY_RELEASES, text: 'Get it' }, + }); + expect(playerProblem('no_notification_access', true)).toEqual({ + text: 'Allow notification access in SubRead Overlay.', + }); + }); + + it('gives null for another reason', () => { + expect(playerProblem('malformed', true)).toBeNull(); + }); +}); + +describe('errorMessage', () => { + it('gives the text of the reason for a PlayerError', () => { + expect(errorMessage(new PlayerError('no_player'), false)).toEqual({ + text: 'Load the audio file first.', + }); + expect(errorMessage(new PlayerError('empty'), true)).toEqual({ + text: 'The player failed: empty.', + }); + }); + + it('gives the error as text for another error', () => { + expect(errorMessage(new Error('boom'), true)).toEqual({ text: 'Error: boom' }); + }); +}); + +describe('playerMessage', () => { + it('gives the time and the play state', () => { + expect(playerMessage(state({ positionMs: 75_000, playing: true }), true)).toEqual({ + text: 'Player 01:15, playing', + }); + expect(playerMessage(state({ positionMs: 5_000 }), false)).toEqual({ + text: 'Audio 00:05, paused', + }); + }); + + it('gives the problem and what to do', () => { + expect(playerMessage(state({ positionMs: null, error: 'no_player' }), false)).toEqual({ + text: 'Load the audio file first.', + }); + expect(playerMessage(state({ error: 'malformed' }), true)).toEqual({ + text: 'Player: cannot read it (malformed).', + }); + expect(playerMessage(state({ positionMs: null }), true)).toEqual({ + text: 'Player: no position.', + }); + expect(playerMessage(null, true)).toEqual({ text: 'Player: not read yet.' }); + }); +}); + +describe('page texts', () => { + it('uses the singular for one page', () => { + expect(pdfLoadedText(1)).toBe('PDF loaded: 1 page.'); + expect(pdfLoadedText(40)).toBe('PDF loaded: 40 pages.'); + expect(pagesReadText({ text: 0, ocr: 1 })).toBe('1 page read: 0 text layer, 1 OCR.'); + expect(pagesReadText({ text: 38, ocr: 2 })).toBe('40 pages read: 38 text layer, 2 OCR.'); + }); + + it('tells the reading progress and the page that is not read yet', () => { + expect(readingText(3, 40, '')).toBe('Reading 3/40'); + expect(readingText(3, 40, 'page 5: OCR 45%')).toBe('Reading 3/40 · page 5: OCR 45%'); + expect(notReadText(3, 40)).toBe('This page is not read yet (3/40 read).'); + }); +});