Files
reader/web/test/tts-text.test.ts
hectorqinandClaude Opus 5.5 d8b3491e95 chore(web): remove the reading engine and helpers nothing runs
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>
2026-10-30 09:49:37 +00:00

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