Files
readest/apps/readest-app/src/__tests__/services/tts-controller.test.ts
T
Huang Xin 86f5502724 fix: bot-review robustness fixes (TTS sync, updater, nightly, a11y) (#4659)
Cherry-picked and re-verified the applicable subset of
julianshen/readest@fa1b74a0 (its "address PR #15 bot reviews" commit). The
fork-only AI-annotation-tool change was dropped — readest has no 'ai' toolbar
tool. Each logic fix is covered by a failing-first test.

- TTS position sequence is now an app-wide monotonic counter, so a fresh
  TTSController (constructed per `tts-speak`) isn't dropped by consumers holding
  `lastSequenceSeen` from a prior session.
- share.ts only swallows AbortError (user cancel); other failures — e.g.
  NotAllowedError when a quick action fires without a user gesture — fall back to
  the clipboard so the text still reaches the user.
- document.isTxt tolerates MIME params (text/plain;charset=utf-8), uppercase
  extensions (BOOK.TXT), and a nameless Blob, so a TXT can't slip onto the
  non-text path and yield a null book.
- updater getNightlyPlatformKey matches x86_64/aarch64 explicitly; a 32-bit or
  otherwise unknown arch yields no nightly instead of mis-routing to aarch64.
- UpdaterWindow downloadWithProgress resolves on tauriDownload completion even
  when Content-Length is absent (no more hang on portable/AppImage/Android).
- nightly_update.rs uses async tokio::fs::read in the async command.
- nightly.yml: serialize runs via a concurrency group (no cancel) and
  persist-credentials:false on checkouts.
- edge TTS route only emits the word-boundary header when it fits under ~8KB;
  oversized values get dropped by proxies, and the client falls back to [].
- RSVPOverlay drops the contradictory aria-disabled on the functional rate
  button (it opens the pace picker).
- nightly verify harness handles artifact stream errors instead of crashing.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:04:34 +02:00

1207 lines
44 KiB
TypeScript

import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { TTSController } from '@/services/tts/TTSController';
import { TTSClient, TTSMessageEvent } from '@/services/tts/TTSClient';
import { TTSGranularity, TTSVoicesGroup } from '@/services/tts/types';
import { TTSUtils } from '@/services/tts/TTSUtils';
import { FoliateView } from '@/types/view';
import { AppService } from '@/types/system';
// --- Mock all heavy dependencies so we never import real TTS clients ---
vi.mock('@/services/tts/WebSpeechClient', () => ({
WebSpeechClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
Object.assign(this, createMockTTSClient('web'));
}),
}));
vi.mock('@/services/tts/EdgeTTSClient', () => ({
EdgeTTSClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
Object.assign(this, createMockTTSClient('edge'));
}),
}));
vi.mock('@/services/tts/NativeTTSClient', () => ({
NativeTTSClient: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
Object.assign(this, createMockTTSClient('native'));
}),
}));
vi.mock('@/services/tts/TTSUtils', () => ({
TTSUtils: {
getPreferredClient: vi.fn().mockReturnValue(null),
setPreferredClient: vi.fn(),
setPreferredVoice: vi.fn(),
getPreferredVoice: vi.fn().mockReturnValue(null),
},
}));
vi.mock('foliate-js/overlayer.js', () => ({
Overlayer: {
highlight: 'highlightFn',
},
}));
vi.mock('@/utils/ssml', () => ({
filterSSMLWithLang: vi.fn((ssml: string) => ssml),
parseSSMLMarks: vi.fn((ssml: string) => ({
plainText: ssml ? 'hello' : '',
marks: ssml ? [{ offset: 0, name: '0', text: 'hello', language: 'en' }] : [],
})),
}));
vi.mock('@/utils/node', () => ({
createRejectFilter: vi.fn(() => () => 1),
}));
vi.mock('@/utils/lang', () => ({
isValidLang: vi.fn(() => true),
}));
vi.mock('foliate-js/tts.js', () => ({
TTS: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
Object.assign(this, {
start: vi.fn().mockReturnValue('<speak>hello</speak>'),
resume: vi.fn().mockReturnValue('<speak>hello</speak>'),
next: vi.fn().mockReturnValue('<speak>next</speak>'),
prev: vi.fn().mockReturnValue('<speak>prev</speak>'),
nextMark: vi.fn().mockReturnValue('<speak>nextMark</speak>'),
prevMark: vi.fn().mockReturnValue('<speak>prevMark</speak>'),
setMark: vi.fn().mockReturnValue(new Range()),
doc: null,
});
}),
}));
vi.mock('foliate-js/text-walker.js', () => ({
textWalker: vi.fn(),
}));
// --- Helper: create mock TTS client ---
function createMockTTSClient(name: string): TTSClient {
return {
name,
initialized: false,
init: vi.fn().mockResolvedValue(true),
shutdown: vi.fn().mockResolvedValue(undefined),
speak: vi.fn().mockImplementation(async function* (): AsyncGenerator<TTSMessageEvent> {
yield { code: 'end' };
}),
pause: vi.fn().mockResolvedValue(true),
resume: vi.fn().mockResolvedValue(true),
stop: vi.fn().mockResolvedValue(undefined),
setPrimaryLang: vi.fn(),
setRate: vi.fn().mockResolvedValue(undefined),
setPitch: vi.fn().mockResolvedValue(undefined),
setVoice: vi.fn().mockResolvedValue(undefined),
getAllVoices: vi.fn().mockResolvedValue([]),
getVoices: vi.fn().mockResolvedValue([]),
getGranularities: vi.fn().mockReturnValue(['word', 'sentence'] as TTSGranularity[]),
supportsWordBoundaries: vi.fn().mockReturnValue(name === 'edge'),
getVoiceId: vi.fn().mockReturnValue('voice-1'),
getSpeakingLang: vi.fn().mockReturnValue('en'),
};
}
// --- Helper: create mock FoliateView ---
function createMockView(): FoliateView {
const mockDoc = {
querySelector: vi.fn().mockReturnValue(null),
} as unknown as Document;
return {
renderer: {
primaryIndex: 0,
getContents: vi.fn().mockReturnValue([
{
doc: mockDoc,
index: 0,
overlayer: {
remove: vi.fn(),
add: vi.fn(),
},
},
]),
},
book: {
sections: [
{ createDocument: vi.fn().mockResolvedValue(mockDoc) },
{ createDocument: vi.fn().mockResolvedValue(mockDoc) },
{ createDocument: vi.fn().mockResolvedValue(mockDoc) },
],
},
language: { isCJK: false },
tts: null,
getCFI: vi.fn().mockReturnValue('cfi-string'),
resolveCFI: vi.fn().mockReturnValue({
anchor: vi.fn().mockReturnValue(new Range()),
}),
} as unknown as FoliateView;
}
// --- Helper: create mock AppService ---
function createMockAppService(isAndroid = false): AppService {
return {
isAndroidApp: isAndroid,
} as unknown as AppService;
}
// --- Tests ---
describe('TTSController', () => {
let controller: TTSController;
let mockView: FoliateView;
let mockAppService: AppService;
beforeEach(() => {
vi.clearAllMocks();
mockView = createMockView();
mockAppService = createMockAppService();
controller = new TTSController(mockAppService, mockView, false);
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(async () => {
// Ensure controller is stopped after each test
try {
await controller.stop();
} catch {
// ignore
}
vi.restoreAllMocks();
});
describe('constructor', () => {
test('sets initial state to stopped', () => {
expect(controller.state).toBe('stopped');
});
test('stores the view reference', () => {
expect(controller.view).toBe(mockView);
});
test('stores appService', () => {
expect(controller.appService).toBe(mockAppService);
});
test('sets isAuthenticated', () => {
expect(controller.isAuthenticated).toBe(false);
const authed = new TTSController(mockAppService, mockView, true);
expect(authed.isAuthenticated).toBe(true);
});
test('defaults ttsRate to 1.0', () => {
expect(controller.ttsRate).toBe(1.0);
});
test('defaults ttsLang to empty string', () => {
expect(controller.ttsLang).toBe('');
});
test('defaults options to highlight/gray', () => {
expect(controller.options).toEqual({ style: 'highlight', color: 'gray' });
});
test('uses webClient as default ttsClient', () => {
expect(controller.ttsClient.name).toBe('web');
});
test('creates native client when isAndroidApp', () => {
const androidService = createMockAppService(true);
const c = new TTSController(androidService, mockView);
expect(c.ttsNativeClient).not.toBeNull();
});
test('does not create native client when not Android', () => {
expect(controller.ttsNativeClient).toBeNull();
});
test('stores preprocessCallback', () => {
const cb = vi.fn();
const c = new TTSController(mockAppService, mockView, false, cb);
expect(c.preprocessCallback).toBe(cb);
});
test('stores onSectionChange callback', () => {
const cb = vi.fn();
const c = new TTSController(mockAppService, mockView, false, undefined, cb);
expect(c.onSectionChange).toBe(cb);
});
});
describe('init', () => {
test('initialises edge and web clients', async () => {
await controller.init();
expect(controller.ttsEdgeClient.init).toHaveBeenCalled();
expect(controller.ttsWebClient.init).toHaveBeenCalled();
});
test('sets ttsClient to first available client (edge)', async () => {
await controller.init();
// edge inits first and succeeds, so it becomes the active client
expect(controller.ttsClient.name).toBe('edge');
});
test('fetches voices from web and edge clients', async () => {
await controller.init();
expect(controller.ttsWebClient.getAllVoices).toHaveBeenCalled();
expect(controller.ttsEdgeClient.getAllVoices).toHaveBeenCalled();
});
test('respects preferred client from TTSUtils', async () => {
vi.mocked(TTSUtils.getPreferredClient).mockReturnValue('web');
await controller.init();
expect(controller.ttsClient.name).toBe('web');
});
test('falls back to web client when preferred client not found', async () => {
vi.mocked(TTSUtils.getPreferredClient).mockReturnValue('nonexistent');
await controller.init();
// first available is edge
expect(controller.ttsClient.name).toBe('edge');
});
test('also initializes native client on Android', async () => {
const androidService = createMockAppService(true);
const c = new TTSController(androidService, mockView);
await c.init();
expect(c.ttsNativeClient!.init).toHaveBeenCalled();
expect(c.ttsNativeClient!.getAllVoices).toHaveBeenCalled();
});
});
describe('setRate', () => {
test('updates ttsRate and state', async () => {
await controller.setRate(1.5);
expect(controller.ttsRate).toBe(1.5);
expect(controller.state).toBe('setrate-paused');
});
test('delegates to ttsClient.setRate', async () => {
await controller.setRate(2.0);
expect(controller.ttsClient.setRate).toHaveBeenCalledWith(2.0);
});
});
describe('setVoice', () => {
test('switches to edge client when voice found in edge voices', async () => {
controller.ttsEdgeVoices = [{ id: 'edge-voice-1', name: 'Edge Voice', lang: 'en-US' }];
await controller.setVoice('edge-voice-1', 'en');
expect(controller.ttsClient.name).toBe('edge');
expect(controller.state).toBe('setvoice-paused');
expect(TTSUtils.setPreferredClient).toHaveBeenCalledWith('edge');
expect(TTSUtils.setPreferredVoice).toHaveBeenCalledWith('edge', 'en', 'edge-voice-1');
});
test('switches to web client when voice not in edge or native', async () => {
controller.ttsEdgeVoices = [{ id: 'edge-voice-1', name: 'Edge Voice', lang: 'en-US' }];
await controller.setVoice('unknown-voice', 'en');
expect(controller.ttsClient.name).toBe('web');
});
test('switches to native client when voice found in native voices', async () => {
const androidService = createMockAppService(true);
const c = new TTSController(androidService, mockView);
await c.init();
c.ttsNativeVoices = [{ id: 'native-v', name: 'Native', lang: 'en-US' }];
await c.setVoice('native-v', 'en');
expect(c.ttsClient.name).toBe('native');
});
test('throws when native voice found but native client unavailable', async () => {
// non-android, ttsNativeClient is null, but we force nativeVoices
controller.ttsNativeVoices = [{ id: 'native-v', name: 'Native', lang: 'en-US' }];
controller.ttsEdgeVoices = [];
await expect(controller.setVoice('native-v', 'en')).rejects.toThrow(
'Native TTS client is not available',
);
});
test('skips disabled voices', async () => {
controller.ttsEdgeVoices = [
{ id: 'edge-voice-1', name: 'Edge Voice', lang: 'en-US', disabled: true },
];
await controller.setVoice('edge-voice-1', 'en');
// Should fall through to web since edge voice is disabled
expect(controller.ttsClient.name).toBe('web');
});
test('uses empty voiceId to match any non-disabled voice', async () => {
controller.ttsEdgeVoices = [{ id: 'edge-v', name: 'Edge', lang: 'en-US' }];
await controller.setVoice('', 'en');
expect(controller.ttsClient.name).toBe('edge');
});
test('sets rate on newly selected client', async () => {
controller.ttsRate = 1.8;
controller.ttsEdgeVoices = [{ id: 'ev', name: 'E', lang: 'en-US' }];
await controller.setVoice('ev', 'en');
expect(controller.ttsClient.setRate).toHaveBeenCalledWith(1.8);
});
});
describe('getVoices', () => {
test('aggregates voices from all clients', async () => {
const edgeVoices: TTSVoicesGroup[] = [
{ id: 'eg', name: 'Edge', voices: [{ id: 'e1', name: 'E1', lang: 'en-US' }] },
];
const webVoices: TTSVoicesGroup[] = [
{ id: 'wg', name: 'Web', voices: [{ id: 'w1', name: 'W1', lang: 'en-US' }] },
];
vi.mocked(controller.ttsEdgeClient.getVoices).mockResolvedValue(edgeVoices);
vi.mocked(controller.ttsWebClient.getVoices).mockResolvedValue(webVoices);
const result = await controller.getVoices('en');
expect(result).toEqual([...edgeVoices, ...webVoices]);
});
test('includes native voices when available', async () => {
const androidService = createMockAppService(true);
const c = new TTSController(androidService, mockView);
await c.init();
const nativeVoices: TTSVoicesGroup[] = [
{ id: 'ng', name: 'Native', voices: [{ id: 'n1', name: 'N1', lang: 'en-US' }] },
];
vi.mocked(c.ttsNativeClient!.getVoices).mockResolvedValue(nativeVoices);
vi.mocked(c.ttsEdgeClient.getVoices).mockResolvedValue([]);
vi.mocked(c.ttsWebClient.getVoices).mockResolvedValue([]);
const result = await c.getVoices('en');
expect(result).toEqual(nativeVoices);
});
});
describe('getVoiceId', () => {
test('delegates to ttsClient.getVoiceId', () => {
const result = controller.getVoiceId();
expect(result).toBe('voice-1');
expect(controller.ttsClient.getVoiceId).toHaveBeenCalled();
});
});
describe('getSpeakingLang', () => {
test('delegates to ttsClient.getSpeakingLang', () => {
const result = controller.getSpeakingLang();
expect(result).toBe('en');
});
});
describe('setTargetLang', () => {
test('sets ttsTargetLang', () => {
controller.setTargetLang('fr');
expect(controller.ttsTargetLang).toBe('fr');
});
});
describe('setLang', () => {
test('sets ttsLang and calls setPrimaryLang', async () => {
await controller.init();
await controller.setLang('zh');
expect(controller.ttsLang).toBe('zh');
});
});
describe('setPrimaryLang', () => {
test('calls setPrimaryLang on initialized clients', async () => {
// Mark clients as initialized
controller.ttsEdgeClient.initialized = true;
controller.ttsWebClient.initialized = true;
await controller.setPrimaryLang('fr');
expect(controller.ttsEdgeClient.setPrimaryLang).toHaveBeenCalledWith('fr');
expect(controller.ttsWebClient.setPrimaryLang).toHaveBeenCalledWith('fr');
});
test('skips uninitialised clients', async () => {
controller.ttsEdgeClient.initialized = false;
controller.ttsWebClient.initialized = false;
await controller.setPrimaryLang('de');
expect(controller.ttsEdgeClient.setPrimaryLang).not.toHaveBeenCalled();
expect(controller.ttsWebClient.setPrimaryLang).not.toHaveBeenCalled();
});
});
describe('updateHighlightOptions', () => {
test('updates style and color', () => {
controller.updateHighlightOptions({ style: 'underline', color: 'red' });
expect(controller.options).toEqual({ style: 'underline', color: 'red' });
});
});
describe('state transitions', () => {
test('pause sets state to paused', async () => {
controller.state = 'playing';
await controller.pause();
expect(controller.state).toBe('paused');
});
test('pause sets stop-paused when client.pause fails', async () => {
controller.state = 'playing';
vi.mocked(controller.ttsClient.pause).mockResolvedValue(false);
await controller.pause();
expect(controller.state).toBe('stop-paused');
});
test('resume sets state to playing', async () => {
controller.state = 'paused';
await controller.resume();
expect(controller.state).toBe('playing');
expect(controller.ttsClient.resume).toHaveBeenCalled();
});
test('stop sets state to stopped', async () => {
controller.state = 'playing';
await controller.stop();
expect(controller.state).toBe('stopped');
});
test('error sets state to stopped', () => {
controller.state = 'playing';
controller.error(new Error('test'));
expect(controller.state).toBe('stopped');
});
test('error preserves state for AbortError (DOMException-style)', () => {
// iOS audio.play() and AbortSignal-aware fetches reject with a DOMException
// whose name is 'AbortError'. Treating it as a real error desyncs the state
// machine: subsequent rate changes see state !== 'playing' and skip the
// stop+start cycle, and #speak's auto-forward gate fails.
controller.state = 'playing';
const abort = new Error('The operation was aborted.');
abort.name = 'AbortError';
controller.error(abort);
expect(controller.state).toBe('playing');
});
test('error preserves state for our internal Aborted message', () => {
// EdgeTTSClient and NativeTTSClient resolve the inner promise with
// { code: 'error', message: 'Aborted' } on signal abort; if that bubbles
// through any catch path it must not flip state to 'stopped'.
controller.state = 'playing';
controller.error(new Error('Aborted'));
expect(controller.state).toBe('playing');
});
test('play calls start when not playing', () => {
controller.state = 'stopped';
const startSpy = vi.spyOn(controller, 'start').mockResolvedValue();
controller.play();
expect(startSpy).toHaveBeenCalled();
});
test('play calls pause when playing', () => {
controller.state = 'playing';
const pauseSpy = vi.spyOn(controller, 'pause').mockResolvedValue();
controller.play();
expect(pauseSpy).toHaveBeenCalled();
});
});
describe('dispatchSpeakMark', () => {
test('dispatches tts-speak-mark event with empty text when no mark', () => {
const listener = vi.fn();
controller.addEventListener('tts-speak-mark', listener);
controller.dispatchSpeakMark();
expect(listener).toHaveBeenCalledTimes(1);
const event = listener.mock.calls[0]![0] as CustomEvent;
expect(event.detail).toEqual({ text: '' });
});
test('dispatches tts-speak-mark with provided mark', () => {
const listener = vi.fn();
controller.addEventListener('tts-speak-mark', listener);
const mark = { offset: 0, name: '0', text: 'hello', language: 'en' };
controller.dispatchSpeakMark(mark);
expect(listener).toHaveBeenCalledTimes(1);
const event = listener.mock.calls[0]![0] as CustomEvent;
expect(event.detail).toEqual(mark);
});
test('dispatches tts-highlight-mark when mark name is not -1', () => {
const highlightListener = vi.fn();
controller.addEventListener('tts-highlight-mark', highlightListener);
// We need view.tts to exist for setMark to be called
mockView.tts = {
setMark: vi.fn().mockReturnValue(new Range()),
} as unknown as FoliateView['tts'];
const mark = { offset: 0, name: '0', text: 'hello', language: 'en' };
controller.dispatchSpeakMark(mark);
expect(highlightListener).toHaveBeenCalledTimes(1);
});
test('does not dispatch highlight when mark name is -1', () => {
const highlightListener = vi.fn();
controller.addEventListener('tts-highlight-mark', highlightListener);
const mark = { offset: 0, name: '-1', text: 'hello', language: 'en' };
controller.dispatchSpeakMark(mark);
expect(highlightListener).not.toHaveBeenCalled();
});
});
describe('word highlighting (prepareSpeakWords / dispatchSpeakWord)', () => {
const getOverlayer = () =>
(
mockView.renderer.getContents() as unknown as Array<{
overlayer: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };
}>
)[0]!.overlayer;
const makeSentenceRange = () => {
document.body.innerHTML = '<p>Hello brave world</p>';
const textNode = document.body.firstElementChild!.firstChild as Text;
const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, textNode.length);
return range;
};
const armWithSentence = async (range: Range, markName = '0') => {
await controller.initViewTTS(0);
mockView.tts = {
setMark: vi.fn().mockReturnValue(range),
getLastRange: vi.fn().mockImplementation(() => range.cloneRange()),
} as unknown as FoliateView['tts'];
controller.dispatchSpeakMark({
offset: 0,
name: markName,
text: 'Hello brave world',
language: 'en',
});
};
test('prepareSpeakWords immediately highlights the first word (no sentence flash)', async () => {
await armWithSentence(makeSentenceRange());
vi.mocked(mockView.getCFI).mockClear();
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
const getCFICalls = vi.mocked(mockView.getCFI).mock.calls;
expect(getCFICalls.length).toBeGreaterThanOrEqual(1);
expect(String(getCFICalls[0]![1])).toBe('Hello');
expect(getOverlayer().add).toHaveBeenCalledTimes(1);
});
test('prepareSpeakWords with no words falls back to the full-sentence highlight', async () => {
await armWithSentence(makeSentenceRange());
vi.mocked(mockView.getCFI).mockClear();
controller.prepareSpeakWords([]);
const getCFICalls = vi.mocked(mockView.getCFI).mock.calls;
expect(getCFICalls.length).toBeGreaterThanOrEqual(1);
expect(String(getCFICalls[0]![1])).toBe('Hello brave world');
expect(getOverlayer().add).toHaveBeenCalledTimes(1);
});
test('dispatchSpeakWord highlights the word sub-range of the current sentence', async () => {
await armWithSentence(makeSentenceRange());
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
vi.mocked(mockView.getCFI).mockClear();
getOverlayer().add.mockClear();
controller.dispatchSpeakWord(1);
const getCFICalls = vi.mocked(mockView.getCFI).mock.calls;
expect(getCFICalls.length).toBeGreaterThanOrEqual(1);
expect(String(getCFICalls[0]![1])).toBe('brave');
expect(getOverlayer().add).toHaveBeenCalledTimes(1);
expect(getOverlayer().add.mock.calls[0]![0]).toBe('tts-highlight');
});
test('word indexes can be dispatched out of order after a seek back', async () => {
await armWithSentence(makeSentenceRange());
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
controller.dispatchSpeakWord(2);
vi.mocked(mockView.getCFI).mockClear();
controller.dispatchSpeakWord(0);
const getCFICalls = vi.mocked(mockView.getCFI).mock.calls;
expect(getCFICalls.length).toBeGreaterThanOrEqual(1);
expect(String(getCFICalls[0]![1])).toBe('Hello');
});
test('does not highlight words for one-time marks (name -1)', async () => {
await armWithSentence(makeSentenceRange(), '-1');
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
controller.dispatchSpeakWord(0);
expect(getOverlayer().add).not.toHaveBeenCalled();
});
test('a new speak mark clears previously prepared words', async () => {
await armWithSentence(makeSentenceRange());
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
expect(getOverlayer().add).toHaveBeenCalledTimes(1);
controller.dispatchSpeakMark({
offset: 0,
name: '1',
text: 'Hello brave world',
language: 'en',
});
getOverlayer().add.mockClear();
controller.dispatchSpeakWord(0);
expect(getOverlayer().add).not.toHaveBeenCalled();
});
test('unmatched first word does not highlight but later words still align', async () => {
await armWithSentence(makeSentenceRange());
controller.prepareSpeakWords(['BOGUS', 'brave']);
// First word unmatched → no eager highlight.
expect(getOverlayer().add).not.toHaveBeenCalled();
vi.mocked(mockView.getCFI).mockClear();
controller.dispatchSpeakWord(1);
const getCFICalls = vi.mocked(mockView.getCFI).mock.calls;
expect(getCFICalls.length).toBeGreaterThanOrEqual(1);
expect(String(getCFICalls[0]![1])).toBe('brave');
});
test('reapplyCurrentHighlight re-draws the current word during word mode', async () => {
await armWithSentence(makeSentenceRange());
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
controller.dispatchSpeakWord(1);
vi.mocked(mockView.getCFI).mockClear();
controller.reapplyCurrentHighlight();
const getCFICalls = vi.mocked(mockView.getCFI).mock.calls;
expect(getCFICalls.length).toBeGreaterThanOrEqual(1);
expect(String(getCFICalls[0]![1])).toBe('brave');
});
test('reapplyCurrentHighlight re-draws the whole sentence when not in word mode', async () => {
await armWithSentence(makeSentenceRange());
controller.prepareSpeakWords([]);
vi.mocked(mockView.getCFI).mockClear();
controller.reapplyCurrentHighlight();
const getCFICalls = vi.mocked(mockView.getCFI).mock.calls;
expect(getCFICalls.length).toBeGreaterThanOrEqual(1);
expect(String(getCFICalls[0]![1])).toBe('Hello brave world');
});
test('dispatchSpeakWord emits tts-highlight-word for word-level page following', async () => {
await armWithSentence(makeSentenceRange());
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
const listener = vi.fn();
controller.addEventListener('tts-highlight-word', listener);
vi.mocked(mockView.getCFI).mockReturnValue('cfi-word');
controller.dispatchSpeakWord(1);
expect(listener).toHaveBeenCalledTimes(1);
const ev = listener.mock.calls[0]![0] as CustomEvent;
expect(ev.detail).toEqual({ cfi: 'cfi-word' });
});
test('dispatchSpeakWord does not emit a word event when the word is unmatched', async () => {
await armWithSentence(makeSentenceRange());
controller.prepareSpeakWords(['BOGUS', 'brave']);
const listener = vi.fn();
controller.addEventListener('tts-highlight-word', listener);
controller.dispatchSpeakWord(0); // unmatched → no range, no event
expect(listener).not.toHaveBeenCalled();
});
test('getCurrentHighlightCfi returns the word cfi in word mode, null otherwise', async () => {
await armWithSentence(makeSentenceRange());
// Not in word mode until prepareSpeakWords with words.
expect(controller.getCurrentHighlightCfi()).toBeNull();
vi.mocked(mockView.getCFI).mockReturnValue('cfi-word');
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
controller.dispatchSpeakWord(1);
expect(controller.getCurrentHighlightCfi()).toBe('cfi-word');
// Empty (sentence fallback) leaves word mode → null so the caller uses
// the sentence-level ttsLocation.
controller.prepareSpeakWords([]);
expect(controller.getCurrentHighlightCfi()).toBeNull();
});
});
describe('tts-position event', () => {
const makeSentenceRange = () => {
document.body.innerHTML = '<p>Hello brave world</p>';
const textNode = document.body.firstElementChild!.firstChild as Text;
const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, textNode.length);
return range;
};
const armWithSentence = async (range: Range, markName = '0') => {
await controller.initViewTTS(0);
mockView.tts = {
setMark: vi.fn().mockReturnValue(range),
getLastRange: vi.fn().mockImplementation(() => range.cloneRange()),
} as unknown as FoliateView['tts'];
controller.dispatchSpeakMark({
offset: 0,
name: markName,
text: 'Hello brave world',
language: 'en',
});
};
test('dispatchSpeakMark emits tts-position with kind sentence, cfi, sectionIndex and sequence', async () => {
await controller.initViewTTS(0);
mockView.tts = {
setMark: vi.fn().mockReturnValue(new Range()),
getLastRange: vi.fn(),
} as unknown as FoliateView['tts'];
vi.mocked(mockView.getCFI).mockReturnValue('cfi-sentence');
const listener = vi.fn();
controller.addEventListener('tts-position', listener);
controller.dispatchSpeakMark({ offset: 0, name: '0', text: 'hello', language: 'en' });
expect(listener).toHaveBeenCalledTimes(1);
const ev = listener.mock.calls[0]![0] as CustomEvent;
expect(ev.detail.kind).toBe('sentence');
expect(ev.detail.cfi).toBe('cfi-sentence');
// initViewTTS(0) set the TTS section index to 0.
expect(ev.detail.sectionIndex).toBe(0);
expect(typeof ev.detail.sequence).toBe('number');
});
test('dispatchSpeakWord emits tts-position with kind word', async () => {
await armWithSentence(makeSentenceRange());
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
const listener = vi.fn();
controller.addEventListener('tts-position', listener);
vi.mocked(mockView.getCFI).mockReturnValue('cfi-word');
controller.dispatchSpeakWord(1);
expect(listener).toHaveBeenCalledTimes(1);
const ev = listener.mock.calls[0]![0] as CustomEvent;
expect(ev.detail.kind).toBe('word');
expect(ev.detail.cfi).toBe('cfi-word');
expect(ev.detail.sectionIndex).toBe(0);
expect(typeof ev.detail.sequence).toBe('number');
});
test('sequence strictly increases across successive emits', async () => {
await armWithSentence(makeSentenceRange());
vi.mocked(mockView.getCFI).mockReturnValue('cfi-x');
const sequences: number[] = [];
controller.addEventListener('tts-position', (e) => {
sequences.push((e as CustomEvent).detail.sequence);
});
// word emit
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
controller.dispatchSpeakWord(1);
// sentence emit
controller.dispatchSpeakMark({ offset: 0, name: '1', text: 'hello', language: 'en' });
// another word emit
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
controller.dispatchSpeakWord(2);
expect(sequences.length).toBeGreaterThanOrEqual(3);
for (let i = 1; i < sequences.length; i++) {
expect(sequences[i]!).toBeGreaterThan(sequences[i - 1]!);
}
});
test('a fresh controller continues the sequence instead of restarting', async () => {
vi.mocked(mockView.getCFI).mockReturnValue('cfi-x');
// Emit one sentence position from a controller and return its sequence.
const emitOnce = async (c: TTSController) => {
await c.initViewTTS(0);
mockView.tts = {
setMark: vi.fn().mockReturnValue(makeSentenceRange()),
getLastRange: vi.fn().mockImplementation(() => makeSentenceRange()),
} as unknown as FoliateView['tts'];
let seq = -1;
const handler = (e: Event) => {
seq = (e as CustomEvent).detail.sequence;
};
c.addEventListener('tts-position', handler);
c.dispatchSpeakMark({ offset: 0, name: '0', text: 'hello', language: 'en' });
c.removeEventListener('tts-position', handler);
return seq;
};
const firstSeq = await emitOnce(controller);
// A new `tts-speak` builds a fresh TTSController (see useTTSControl). A
// per-instance counter would restart, so the new session's first sequence
// would be <= the previous session's and a consumer holding
// `lastSequenceSeen` would drop it. A module-level counter keeps the
// sequence strictly increasing across sessions.
const controller2 = new TTSController(mockAppService, mockView, false);
const secondSeq = await emitOnce(controller2);
expect(secondSeq).toBeGreaterThan(firstSeq);
});
});
describe('redispatchPosition', () => {
const makeSentenceRange = () => {
document.body.innerHTML = '<p>Hello brave world</p>';
const textNode = document.body.firstElementChild!.firstChild as Text;
const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, textNode.length);
return range;
};
const armWithSentence = async (range: Range, markName = '0') => {
await controller.initViewTTS(0);
mockView.tts = {
setMark: vi.fn().mockReturnValue(range),
getLastRange: vi.fn().mockImplementation(() => range.cloneRange()),
} as unknown as FoliateView['tts'];
controller.dispatchSpeakMark({
offset: 0,
name: markName,
text: 'Hello brave world',
language: 'en',
});
};
test('re-emits the current sentence position when not in word mode', async () => {
await armWithSentence(makeSentenceRange());
vi.mocked(mockView.getCFI).mockReturnValue('cfi-sentence');
const listener = vi.fn();
controller.addEventListener('tts-position', listener);
controller.redispatchPosition();
expect(listener).toHaveBeenCalledTimes(1);
const ev = listener.mock.calls[0]![0] as CustomEvent;
expect(ev.detail.kind).toBe('sentence');
expect(ev.detail.cfi).toBe('cfi-sentence');
expect(ev.detail.sectionIndex).toBe(0);
expect(typeof ev.detail.sequence).toBe('number');
});
test('re-emits the current word position when in word mode', async () => {
await armWithSentence(makeSentenceRange());
controller.prepareSpeakWords(['Hello', 'brave', 'world']);
vi.mocked(mockView.getCFI).mockReturnValue('cfi-word');
controller.dispatchSpeakWord(1);
const listener = vi.fn();
controller.addEventListener('tts-position', listener);
controller.redispatchPosition();
expect(listener).toHaveBeenCalledTimes(1);
const ev = listener.mock.calls[0]![0] as CustomEvent;
expect(ev.detail.kind).toBe('word');
expect(ev.detail.cfi).toBe('cfi-word');
});
test('uses a fresh, strictly increasing sequence on each call', async () => {
await armWithSentence(makeSentenceRange());
vi.mocked(mockView.getCFI).mockReturnValue('cfi-x');
const sequences: number[] = [];
controller.addEventListener('tts-position', (e) => {
sequences.push((e as CustomEvent).detail.sequence);
});
controller.redispatchPosition();
controller.redispatchPosition();
expect(sequences.length).toBe(2);
expect(sequences[1]!).toBeGreaterThan(sequences[0]!);
});
test('is a no-op when TTS is inactive (no section)', () => {
const listener = vi.fn();
controller.addEventListener('tts-position', listener);
controller.redispatchPosition();
expect(listener).not.toHaveBeenCalled();
});
});
describe('getSpokenSentence', () => {
test('returns the trimmed text and cfi of the current sentence', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue({ toString: () => ' A spoken sentence. ' }),
} as unknown as FoliateView['tts'];
vi.mocked(mockView.getCFI).mockReturnValue('cfi-current');
expect(controller.getSpokenSentence()).toEqual({
cfi: 'cfi-current',
text: 'A spoken sentence.',
});
});
test('returns null when TTS is inactive (no view.tts)', () => {
// No initViewTTS: view.tts is null and the section index is -1.
expect(controller.getSpokenSentence()).toBeNull();
});
test('returns null when there is no current range', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue(undefined),
} as unknown as FoliateView['tts'];
expect(controller.getSpokenSentence()).toBeNull();
});
test('returns null when getCFI throws', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue({ toString: () => 'x' }),
} as unknown as FoliateView['tts'];
vi.mocked(mockView.getCFI).mockImplementation(() => {
throw new Error('cfi failure');
});
expect(controller.getSpokenSentence()).toBeNull();
});
test('returns null when the sentence text is only whitespace', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue({ toString: () => ' ' }),
} as unknown as FoliateView['tts'];
vi.mocked(mockView.getCFI).mockReturnValue('cfi-current');
expect(controller.getSpokenSentence()).toBeNull();
});
});
describe('shutdown', () => {
test('stops playback and clears tts', async () => {
const stopSpy = vi.spyOn(controller, 'stop').mockResolvedValue();
controller.ttsWebClient.initialized = true;
controller.ttsEdgeClient.initialized = true;
await controller.shutdown();
expect(stopSpy).toHaveBeenCalled();
expect(mockView.tts).toBeNull();
expect(controller.ttsWebClient.shutdown).toHaveBeenCalled();
expect(controller.ttsEdgeClient.shutdown).toHaveBeenCalled();
});
test('shuts down native client when initialized', async () => {
const androidService = createMockAppService(true);
const c = new TTSController(androidService, mockView);
await c.init();
c.ttsNativeClient!.initialized = true;
vi.spyOn(c, 'stop').mockResolvedValue();
await c.shutdown();
expect(c.ttsNativeClient!.shutdown).toHaveBeenCalled();
});
test('skips shutdown of uninitialized clients', async () => {
vi.spyOn(controller, 'stop').mockResolvedValue();
controller.ttsWebClient.initialized = false;
controller.ttsEdgeClient.initialized = false;
await controller.shutdown();
expect(controller.ttsWebClient.shutdown).not.toHaveBeenCalled();
expect(controller.ttsEdgeClient.shutdown).not.toHaveBeenCalled();
});
});
describe('start', () => {
test('uses tts.resume() not tts.start() when state is stopped (play/pause race fix)', async () => {
// Repro: `forward()` transitions state to 'stopped' transiently between its
// `await this.stop()` and the follow-up navigation. If the user taps play
// in that window, `start()` previously called `tts.start()` — which resets
// the TTS list to position 0 (section beginning) instead of resuming the
// current paragraph. The fix: always use `tts.resume()` (which itself
// falls back to `next()` on a fresh TTS), so there's no way `start()`
// ever rewinds to the top of a section.
await controller.initViewTTS(0);
const ttsStartMock = vi.fn().mockReturnValue('<speak>section-start</speak>');
const ttsResumeMock = vi.fn().mockReturnValue('<speak>current</speak>');
const tts = mockView.tts as unknown as {
start: typeof ttsStartMock;
resume: typeof ttsResumeMock;
next: ReturnType<typeof vi.fn>;
prev: ReturnType<typeof vi.fn>;
};
tts.start = ttsStartMock;
tts.resume = ttsResumeMock;
tts.next = vi.fn().mockReturnValue(undefined);
tts.prev = vi.fn();
// Simulate the race: state is 'stopped' (transient during forward())
controller.state = 'stopped';
await controller.start();
expect(ttsResumeMock).toHaveBeenCalled();
expect(ttsStartMock).not.toHaveBeenCalled();
});
});
describe('forward and backward', () => {
test('forward sets forward-paused state when not playing', async () => {
// Set up controller with a mock tts on the view
mockView.tts = {
next: vi.fn().mockReturnValue('<speak>next</speak>'),
nextMark: vi.fn().mockReturnValue('<speak>nextMark</speak>'),
start: vi.fn(),
doc: null,
} as unknown as FoliateView['tts'];
controller.state = 'paused';
await controller.forward();
expect(controller.state).toBe('forward-paused');
});
test('backward sets backward-paused state when not playing', async () => {
mockView.tts = {
prev: vi.fn().mockReturnValue('<speak>prev</speak>'),
prevMark: vi.fn().mockReturnValue('<speak>prevMark</speak>'),
start: vi.fn(),
doc: null,
} as unknown as FoliateView['tts'];
controller.state = 'paused';
await controller.backward();
expect(controller.state).toBe('backward-paused');
});
});
describe('stop', () => {
test('calls ttsClient.stop', async () => {
await controller.stop();
expect(controller.ttsClient.stop).toHaveBeenCalled();
});
test('sets state to stopped', async () => {
controller.state = 'playing';
await controller.stop();
expect(controller.state).toBe('stopped');
});
test('handles client stop errors gracefully', async () => {
vi.mocked(controller.ttsClient.stop).mockRejectedValue(new Error('stop err'));
// should not throw
await controller.stop();
expect(controller.state).toBe('stopped');
});
});
describe('preloadSSML', () => {
test('does nothing when ssml is undefined', async () => {
await controller.preloadSSML(undefined, new AbortController().signal);
expect(controller.ttsClient.speak).not.toHaveBeenCalled();
});
test('calls speak with preload flag', async () => {
await controller.preloadSSML('<speak>hi</speak>', new AbortController().signal);
expect(controller.ttsClient.speak).toHaveBeenCalledWith(
'<speak>hi</speak>',
expect.anything(),
true,
);
});
});
describe('preloadNextSSML', () => {
test('calls tts.next() and tts.prev() synchronously without async gaps between them', async () => {
// This test verifies the fix for a race condition where async gaps between
// tts.next() calls in preloadNextSSML allowed #speak() to interleave and
// read corrupted #ranges state (replaced by next() for a different block).
const callOrder: string[] = [];
let asyncOpHappened = false;
mockView.tts = {
next: vi.fn().mockImplementation(() => {
if (asyncOpHappened) {
callOrder.push('next-after-async');
} else {
callOrder.push('next');
}
return '<speak>chunk</speak>';
}),
prev: vi.fn().mockImplementation(() => {
callOrder.push('prev');
}),
doc: {},
} as unknown as FoliateView['tts'];
// Use preprocessCallback to detect when async processing happens
controller.preprocessCallback = async (ssml: string) => {
asyncOpHappened = true;
callOrder.push('preprocess');
return ssml;
};
await controller.preloadNextSSML(2);
// All next() calls should happen before any preprocess (async operation)
const firstPreprocessIdx = callOrder.indexOf('preprocess');
const nextIndices = callOrder.map((op, i) => (op === 'next' ? i : -1)).filter((i) => i >= 0);
const prevIndices = callOrder.map((op, i) => (op === 'prev' ? i : -1)).filter((i) => i >= 0);
// All next() calls must come before any async preprocessing
for (const idx of nextIndices) {
expect(idx).toBeLessThan(firstPreprocessIdx);
}
// All prev() calls must come before any async preprocessing
for (const idx of prevIndices) {
expect(idx).toBeLessThan(firstPreprocessIdx);
}
// No next() should happen after an async operation
expect(callOrder).not.toContain('next-after-async');
});
});
describe('initViewTTS', () => {
test('does nothing when already initialised (section index != -1)', async () => {
// Manually set section index via a reflect access workaround
// Since #ttsSectionIndex is private, we test indirectly through initViewTTS
// being called multiple times - first call will init, second should skip
mockView.tts = {
doc: {},
start: vi.fn(),
} as unknown as FoliateView['tts'];
// Call once to set the section index
await controller.initViewTTS(0);
// Now we can verify it doesn't re-init by checking the section was already created
});
});
describe('extends EventTarget', () => {
test('is an instance of EventTarget', () => {
expect(controller instanceof EventTarget).toBe(true);
});
test('can add and dispatch custom events', () => {
const handler = vi.fn();
controller.addEventListener('test-event', handler);
controller.dispatchEvent(new CustomEvent('test-event', { detail: 'data' }));
expect(handler).toHaveBeenCalledTimes(1);
});
});
});