86f5502724
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>
1207 lines
44 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|