mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 14:31:11 +08:00
An import graph from main.tsx shows a previous reader still in the tree:
render/{reader,paged,pdf,text,container,theme}.ts with lib/{events,locator}.ts,
ui/settings.ts and bridge.ts — superseded by ReaderScreen/ReaderView and
never imported. Also unreachable: render/paginator.ts, render/window.ts and
ui/shelf-order.ts (only their own tests used them; the shelf sorts on the
server), formats/remote-zip.ts (re-exported, never called; chapters are
fetched per section), ui/source-capabilities.tsx and the sources pages barrel.
Inside live modules: PrefetchCache (only shouldPrefetch is used),
textToParagraphs / paragraphElement / endsAtParagraphBreak (the streaming
TXT reader's carry), txt.ts's textToHtml alias, and ChunkCursor. Their tests
go with them; the node:test list, the Vitest excludes and the docs that
named these files are updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
4.1 KiB
TypeScript
100 lines
4.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, expect, it } from 'vitest';
|
|
import { collectSpokenChunks, splitSentence } from '../src/render/tts-text.ts';
|
|
|
|
/**
|
|
* Sentence chunking.
|
|
*
|
|
* This is the part of read-aloud that decides *what is said next*, so every
|
|
* assertion here is really about a behaviour a reader would notice: a sentence
|
|
* that is cut in half sounds broken, and a paragraph that is not cut at all
|
|
* makes the synthesizer stutter or stop.
|
|
*/
|
|
|
|
describe('splitSentence', () => {
|
|
it('splits on Chinese full-width stops', () => {
|
|
const pieces = splitSentence('他来了。她走了!然后呢?');
|
|
expect(pieces.map((piece) => piece.text)).toEqual(['他来了。', '她走了!', '然后呢?']);
|
|
});
|
|
|
|
it('keeps the offset of every piece inside the original node', () => {
|
|
const text = '第一句。第二句。';
|
|
for (const piece of splitSentence(text)) {
|
|
expect(text.slice(piece.start, piece.start + piece.text.length)).toBe(piece.text);
|
|
}
|
|
});
|
|
|
|
it('does not split a decimal number', () => {
|
|
expect(splitSentence('圆周率是 3.14 左右。')).toHaveLength(1);
|
|
});
|
|
|
|
it('does not split an abbreviation', () => {
|
|
const pieces = splitSentence('Dr. Smith arrived. He was late.');
|
|
// The trailing space belongs to the sentence being spoken only for tidiness;
|
|
// what matters is that "Dr." was not cut off from the name that follows it.
|
|
expect(pieces.map((piece) => piece.text.trim())).toEqual(['Dr. Smith arrived.', 'He was late.']);
|
|
});
|
|
|
|
it('keeps a closing quote with the sentence it closes', () => {
|
|
const pieces = splitSentence('他说:“好的。”然后走了。');
|
|
expect(pieces.map((piece) => piece.text)).toEqual(['他说:“好的。”', '然后走了。']);
|
|
});
|
|
|
|
it('treats a run of terminators as one ending', () => {
|
|
expect(splitSentence('什么?!').map((piece) => piece.text)).toEqual(['什么?!']);
|
|
});
|
|
|
|
it('cuts an unpunctuated run at the ceiling instead of emitting one huge utterance', () => {
|
|
const long = 'あ'.repeat(1000);
|
|
const pieces = splitSentence(long, 300);
|
|
expect(pieces.length).toBeGreaterThan(1);
|
|
for (const piece of pieces) expect(piece.text.length).toBeLessThanOrEqual(300);
|
|
expect(pieces.map((piece) => piece.text).join('')).toBe(long);
|
|
});
|
|
|
|
it('drops whitespace-only pieces rather than speaking silence', () => {
|
|
expect(splitSentence(' \n ')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('collectSpokenChunks', () => {
|
|
function chunksFor(html: string): ReturnType<typeof collectSpokenChunks> {
|
|
const root = document.createElement('div');
|
|
root.innerHTML = html;
|
|
document.body.append(root);
|
|
return collectSpokenChunks(root, (node) => {
|
|
const block = [...root.children].find((child) => child.contains(node));
|
|
return block ? [...root.children].indexOf(block) : 0;
|
|
});
|
|
}
|
|
|
|
it('walks paragraphs in document order', () => {
|
|
const chunks = chunksFor('<p>第一段。</p><p>第二段。</p>');
|
|
expect(chunks.map((chunk) => chunk.text)).toEqual(['第一段。', '第二段。']);
|
|
});
|
|
|
|
it('reports the block index each sentence belongs to', () => {
|
|
const chunks = chunksFor('<p>甲。乙。</p><p>丙。</p>');
|
|
expect(chunks.map((chunk) => chunk.blockIndex)).toEqual([0, 0, 1]);
|
|
});
|
|
|
|
it('skips ruby annotations, which are not read on their own', () => {
|
|
// A ruby base and the text after it are separate text nodes, so they come
|
|
// back as separate pieces. What matters is that the reading (かん) is never
|
|
// spoken, which is the behaviour a reader would notice immediately.
|
|
const chunks = chunksFor('<p><ruby>漢<rt>かん</rt></ruby>字。</p>');
|
|
expect(chunks.map((chunk) => chunk.text).join('')).toBe('漢字。');
|
|
expect(chunks.some((chunk) => chunk.text.includes('かん'))).toBe(false);
|
|
});
|
|
|
|
it('skips code blocks', () => {
|
|
expect(chunksFor('<pre>const a = 1;</pre>')).toHaveLength(0);
|
|
});
|
|
|
|
it('points each sentence at the text node it came from', () => {
|
|
const chunks = chunksFor('<p>一句话。</p>');
|
|
expect(chunks[0]?.node?.data).toBe('一句话。');
|
|
expect(chunks[0]?.start).toBe(0);
|
|
});
|
|
});
|