forked from akai/readest
17de9357dd
* feat(reader): add formatCountdown helper for TTS timer chips Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): extract shared TTS playback info hook Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add TTS scrubber with buffer-ahead fill Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add TTS speed preset chips Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add persistent TTS mini player Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add full TTS player sheet with voice and timer sub-views Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(reader): cover player sheet view reset on reopen Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): replace TTS icon and popup with mini player and player sheet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): reserve mini player clearance and retire showTTSBar setting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retire shipped TTS follow-ups from TODOS Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(i18n): translate the TTS player strings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): pin mini player transport LTR and unmount the closed player sheet Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): collapse sheet speed, voice, and timer controls into one row Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): stabilize timeline estimates with cumulative voice calibration Replace the per-sentence EMA with the cumulative ratio of all measured chars to all measured seconds per voice, so the estimated remainder converges instead of re-pricing on every quirky sentence. Legacy stored calibrations migrate as a small prior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): hide the mini player while the player sheet is open Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tts): clear stale highlights across sections and stop sentence flash in word mode Entering a section now scrubs the TTS highlight from every live view, not just the primary, so the outgoing section's last word no longer stays lit in the preloaded neighbor. reapplyCurrentHighlight no longer redraws the whole sentence during word-mode playback while awaiting the first word boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(reader): move the mini player progress line to the bottom edge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): make the TTS progress bar and scrubber legible in eink mode Grey tints wash out on e-ink: the mini player track gets a 1px hairline with a solid base-content fill (buffer fill hidden), and the sheet scrubber gets a crisp 1px border marking the track extent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style(reader): drop the player sheet header label on the main view Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
652 lines
21 KiB
TypeScript
652 lines
21 KiB
TypeScript
import { act, cleanup, render } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// --- Dependency mocks (must be set up before importing the hook) ---
|
|
|
|
vi.mock('@/context/EnvContext', () => ({
|
|
useEnv: () => ({
|
|
appService: { isIOSApp: false, isMobile: false },
|
|
envConfig: {},
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@/context/AuthContext', () => ({
|
|
useAuth: () => ({ user: null }),
|
|
}));
|
|
|
|
vi.mock('@/store/themeStore', () => ({
|
|
useThemeStore: () => ({ isDarkMode: false }),
|
|
}));
|
|
|
|
const mockView = {
|
|
book: { primaryLanguage: 'en', sections: [{ id: 0 }] },
|
|
renderer: {
|
|
getContents: () => [{ index: 0, doc: document as unknown as Document }],
|
|
scrollToAnchor: vi.fn(),
|
|
primaryIndex: 0,
|
|
scrolled: false,
|
|
nextSection: vi.fn(),
|
|
start: 0,
|
|
end: 0,
|
|
sideProp: 'height',
|
|
goTo: vi.fn(),
|
|
},
|
|
resolveCFI: vi.fn().mockReturnValue({ index: 0, anchor: () => new Range() }),
|
|
getCFI: vi.fn().mockReturnValue('cfi'),
|
|
deselect: vi.fn(),
|
|
resolveNavigation: vi.fn(),
|
|
goTo: vi.fn(),
|
|
history: { back: vi.fn(), forward: vi.fn() },
|
|
tts: {
|
|
from: vi.fn().mockReturnValue('<speak>hello</speak>'),
|
|
start: vi.fn().mockReturnValue('<speak>hello</speak>'),
|
|
getLastRange: vi.fn().mockReturnValue(null),
|
|
highlight: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const mockProgress = {
|
|
location: { start: { cfi: '' }, end: { cfi: '' } },
|
|
index: 0,
|
|
range: null,
|
|
sectionLabel: '',
|
|
};
|
|
|
|
const mockViewSettings = {
|
|
ttsLocation: null as string | null,
|
|
ttsRate: 1,
|
|
ttsHighlightOptions: { style: 'highlight', color: '#ffff00' },
|
|
isEink: false,
|
|
ttsMediaMetadata: 'sentence',
|
|
translationEnabled: false,
|
|
ttsReadAloudText: 'source',
|
|
};
|
|
|
|
const mockBookData = {
|
|
isFixedLayout: false,
|
|
book: { primaryLanguage: 'en', title: 'T', author: 'A', coverImageUrl: '' },
|
|
};
|
|
|
|
vi.mock('@/store/readerStore', () => {
|
|
const store = {
|
|
hoveredBookKey: null,
|
|
bookKeys: ['book-1'],
|
|
getView: () => mockView,
|
|
getProgress: () => mockProgress,
|
|
getViewSettings: () => mockViewSettings,
|
|
setViewSettings: vi.fn(),
|
|
setTTSEnabled: vi.fn(),
|
|
};
|
|
// Production code uses per-field selectors; mock must apply them.
|
|
const useReaderStore = <R,>(selector?: (s: typeof store) => R) =>
|
|
selector ? selector(store) : store;
|
|
useReaderStore.getState = () => store;
|
|
return { useReaderStore };
|
|
});
|
|
|
|
vi.mock('@/store/bookDataStore', () => {
|
|
const state = { getBookData: () => mockBookData };
|
|
return {
|
|
useBookDataStore: <R,>(selector?: (s: typeof state) => R) =>
|
|
selector ? selector(state) : state,
|
|
};
|
|
});
|
|
|
|
// useTTSControl now reads progress reactively from readerProgressStore.
|
|
vi.mock('@/store/readerProgressStore', () => ({
|
|
useBookProgress: () => mockProgress,
|
|
getBookProgress: () => mockProgress,
|
|
}));
|
|
|
|
vi.mock('@/store/proofreadStore', () => ({
|
|
useProofreadStore: () => ({
|
|
getMergedRules: () => [],
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@/services/transformers/proofread', () => ({
|
|
proofreadTransformer: {
|
|
transform: vi.fn(async (ctx: { content: string }) => ctx.content),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/hooks/useTranslation', () => ({
|
|
useTranslation: () => (s: string) => s,
|
|
}));
|
|
|
|
// Track TTSController instantiations — this is the assertion target.
|
|
const ttsControllerInstances: unknown[] = [];
|
|
// Gate init() calls so that handleTTSSpeak stays suspended inside an `await`.
|
|
// This is the exact point where a second concurrent invocation would otherwise
|
|
// race ahead and construct a second TTSController. The test releases all
|
|
// pending resolvers once both dispatches have had a chance to interleave.
|
|
const pendingInitResolvers: Array<() => void> = [];
|
|
|
|
vi.mock('@/services/tts', () => ({
|
|
TTSController: vi.fn().mockImplementation(function (this: Record<string, unknown>) {
|
|
Object.assign(this, {
|
|
init: vi.fn().mockImplementation(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
pendingInitResolvers.push(() => resolve());
|
|
}),
|
|
),
|
|
initViewTTS: vi.fn().mockResolvedValue(undefined),
|
|
updateHighlightOptions: vi.fn(),
|
|
setHighlightGranularity: vi.fn(),
|
|
setLang: vi.fn(),
|
|
setRate: vi.fn(),
|
|
setVoice: vi.fn(),
|
|
setTargetLang: vi.fn(),
|
|
speak: vi.fn(),
|
|
pause: vi.fn().mockResolvedValue(undefined),
|
|
resume: vi.fn().mockResolvedValue(undefined),
|
|
stop: vi.fn().mockResolvedValue(undefined),
|
|
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
forward: vi.fn().mockResolvedValue(undefined),
|
|
backward: vi.fn().mockResolvedValue(undefined),
|
|
getVoices: vi.fn().mockResolvedValue([]),
|
|
getVoiceId: vi.fn().mockReturnValue(''),
|
|
redispatchPosition: vi.fn(),
|
|
ensureTimeline: vi.fn().mockResolvedValue(null),
|
|
getPlaybackInfo: vi.fn().mockReturnValue(null),
|
|
seekToTime: vi.fn().mockResolvedValue(undefined),
|
|
detachView: vi.fn(),
|
|
attachView: vi.fn().mockResolvedValue(undefined),
|
|
getSpeakingLang: vi.fn().mockReturnValue('en'),
|
|
terminated: false,
|
|
isViewAttached: true,
|
|
state: 'idle',
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
});
|
|
ttsControllerInstances.push(this);
|
|
}),
|
|
ensureSharedAudioContext: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock('@/libs/mediaSession', () => ({
|
|
TauriMediaSession: class {},
|
|
getMediaSession: vi.fn(() => null),
|
|
}));
|
|
|
|
const { mockSessionManager } = vi.hoisted(() => ({
|
|
mockSessionManager: {
|
|
claim: vi.fn(),
|
|
detach: vi.fn(),
|
|
release: vi.fn(),
|
|
adopt: vi.fn(),
|
|
getSessionByHash: vi.fn((_hash: string) => null as unknown),
|
|
getActiveSession: vi.fn(() => null as unknown),
|
|
stopActive: vi.fn().mockResolvedValue(undefined),
|
|
setSleepTimer: vi.fn(),
|
|
getSleepTimer: vi.fn(() => null),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/services/tts/TTSSessionManager', () => ({
|
|
getBookHashFromKey: (key: string) => key.split('-')[0]!,
|
|
ttsSessionManager: mockSessionManager,
|
|
}));
|
|
|
|
vi.mock('@/utils/ssml', () => ({
|
|
genSSMLRaw: vi.fn((s: string) => `<speak>${s}</speak>`),
|
|
parseSSMLLang: vi.fn(() => 'en'),
|
|
}));
|
|
|
|
vi.mock('@/utils/throttle', () => ({
|
|
throttle: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
|
}));
|
|
|
|
vi.mock('@/utils/cfi', () => ({
|
|
isCfiInLocation: () => false,
|
|
}));
|
|
|
|
vi.mock('@/utils/misc', () => ({
|
|
getLocale: () => 'en',
|
|
stubTranslation: (key: string) => key,
|
|
}));
|
|
|
|
vi.mock('@/utils/ttsMetadata', () => ({
|
|
buildTTSMediaMetadata: () => ({
|
|
shouldUpdate: false,
|
|
title: '',
|
|
artist: '',
|
|
album: '',
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@/utils/bridge', () => ({
|
|
invokeUseBackgroundAudio: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock('@/utils/ttsTime', () => ({
|
|
estimateTTSTime: () => ({
|
|
chapterRemainingSec: 0,
|
|
bookRemainingSec: 0,
|
|
finishAtTimestamp: 0,
|
|
}),
|
|
}));
|
|
|
|
// Imports must come AFTER vi.mock calls so they pick up the mocked modules.
|
|
import { useTTSControl } from '@/app/reader/hooks/useTTSControl';
|
|
import { ttsMediaBridge } from '@/services/tts/ttsMediaBridge';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { useReaderStore } from '@/store/readerStore';
|
|
|
|
const getSetTTSEnabledMock = () =>
|
|
(
|
|
useReaderStore as unknown as {
|
|
getState: () => { setTTSEnabled: ReturnType<typeof vi.fn> };
|
|
}
|
|
).getState().setTTSEnabled;
|
|
|
|
const Harness = () => {
|
|
useTTSControl({ bookKey: 'book-1' });
|
|
return null;
|
|
};
|
|
|
|
describe('useTTSControl concurrent tts-speak events', () => {
|
|
beforeEach(() => {
|
|
ttsControllerInstances.length = 0;
|
|
pendingInitResolvers.length = 0;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
it('creates only one TTSController when two tts-speak events fire back-to-back', async () => {
|
|
render(<Harness />);
|
|
|
|
await act(async () => {
|
|
// Kick off both dispatches without awaiting — this models rapid clicks
|
|
// where the second click arrives while the first is still inside its
|
|
// initial awaits (initMediaSession / backgroundAudio / init()).
|
|
const p1 = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
|
|
const p2 = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
|
|
|
|
// Let both invocations drain microtasks and reach their gated await.
|
|
// Without the single-flight guard in handleTTSSpeak, both invocations
|
|
// would construct a TTSController here and both would be queued in
|
|
// pendingInitResolvers.
|
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
|
|
// The assertion that matters: exactly one controller was constructed.
|
|
expect(ttsControllerInstances.length).toBe(1);
|
|
|
|
// Release any pending init() promises so the dispatch chain can unwind
|
|
// cleanly (otherwise the act() would never settle).
|
|
while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!();
|
|
await Promise.all([p1, p2]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('useTTSControl tts-sync-request (mode-entry replay)', () => {
|
|
beforeEach(() => {
|
|
ttsControllerInstances.length = 0;
|
|
pendingInitResolvers.length = 0;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
const startSession = async () => {
|
|
render(<Harness />);
|
|
await act(async () => {
|
|
const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
|
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!();
|
|
await p;
|
|
});
|
|
await act(async () => {
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
});
|
|
return ttsControllerInstances[0] as { redispatchPosition: ReturnType<typeof vi.fn> };
|
|
};
|
|
|
|
it('replays the current position then the playback state when a session exists', async () => {
|
|
const controller = await startSession();
|
|
const order: string[] = [];
|
|
controller.redispatchPosition.mockImplementation(() => order.push('position'));
|
|
const stateListener = (e: Event) => {
|
|
order.push(`state:${(e as CustomEvent).detail.state}`);
|
|
};
|
|
eventDispatcher.on('tts-playback-state', stateListener);
|
|
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-sync-request', { bookKey: 'book-1' });
|
|
});
|
|
|
|
eventDispatcher.off('tts-playback-state', stateListener);
|
|
// Position-before-state is required so RSVP's 'paused' handler (which drops
|
|
// following) can't discard the replayed position.
|
|
expect(order).toEqual(['position', 'state:playing']);
|
|
});
|
|
|
|
it('ignores a sync request for a different book', async () => {
|
|
const controller = await startSession();
|
|
controller.redispatchPosition.mockClear();
|
|
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-sync-request', { bookKey: 'other-book' });
|
|
});
|
|
|
|
expect(controller.redispatchPosition).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('is a no-op once the session has stopped', async () => {
|
|
const controller = await startSession();
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' });
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
});
|
|
controller.redispatchPosition.mockClear();
|
|
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-sync-request', { bookKey: 'book-1' });
|
|
});
|
|
|
|
expect(controller.redispatchPosition).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('useTTSControl handleStop resilience (#4676)', () => {
|
|
beforeEach(() => {
|
|
ttsControllerInstances.length = 0;
|
|
pendingInitResolvers.length = 0;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
const startSession = async () => {
|
|
render(<Harness />);
|
|
await act(async () => {
|
|
const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
|
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!();
|
|
await p;
|
|
});
|
|
await act(async () => {
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
});
|
|
return ttsControllerInstances[0] as { shutdown: ReturnType<typeof vi.fn> };
|
|
};
|
|
|
|
it('disables TTS even when controller.shutdown rejects', async () => {
|
|
// Regression: a native teardown that throws (observed with iOS system TTS)
|
|
// must not skip the state resets that turn the TTS icon off.
|
|
const controller = await startSession();
|
|
const setTTSEnabled = getSetTTSEnabledMock();
|
|
setTTSEnabled.mockClear();
|
|
controller.shutdown.mockRejectedValueOnce(new Error('native teardown failed'));
|
|
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' });
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
});
|
|
|
|
expect(setTTSEnabled).toHaveBeenCalledWith('book-1', false);
|
|
});
|
|
|
|
it('disables TTS even when controller.shutdown never resolves', async () => {
|
|
// The state resets must run before (not after) the teardown await, so a
|
|
// hung native teardown can never leave the TTS icon stuck on.
|
|
const controller = await startSession();
|
|
const setTTSEnabled = getSetTTSEnabledMock();
|
|
setTTSEnabled.mockClear();
|
|
controller.shutdown.mockReturnValueOnce(new Promise<void>(() => {}));
|
|
|
|
await act(async () => {
|
|
// Do not await the dispatch: handleStop intentionally never settles here.
|
|
eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' });
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
});
|
|
|
|
expect(setTTSEnabled).toHaveBeenCalledWith('book-1', false);
|
|
});
|
|
|
|
it('tears down the media session even when controller.shutdown never resolves', async () => {
|
|
// Regression for the lock-screen Now Playing lingering with iOS system TTS:
|
|
// the media-session teardown must not be gated behind the controller's own
|
|
// shutdown, which can stall. The media session is owned by ttsMediaBridge
|
|
// now; the teardown is its unbind().
|
|
const unbindSpy = vi.spyOn(ttsMediaBridge, 'unbind');
|
|
const controller = await startSession();
|
|
unbindSpy.mockClear();
|
|
controller.shutdown.mockReturnValueOnce(new Promise<void>(() => {}));
|
|
|
|
await act(async () => {
|
|
eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' });
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
});
|
|
|
|
expect(unbindSpy).toHaveBeenCalled();
|
|
unbindSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('useTTSControl handleHighlightMark cross-section navigation', () => {
|
|
beforeEach(() => {
|
|
ttsControllerInstances.length = 0;
|
|
pendingInitResolvers.length = 0;
|
|
mockView.renderer.scrollToAnchor.mockClear();
|
|
mockView.renderer.goTo.mockClear();
|
|
mockView.goTo.mockClear();
|
|
mockView.resolveCFI.mockReset();
|
|
mockViewSettings.ttsLocation = null;
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
const setupAndCaptureHighlightHandler = async () => {
|
|
render(<Harness />);
|
|
|
|
await act(async () => {
|
|
const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
|
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!();
|
|
await p;
|
|
});
|
|
|
|
// Let the listener-registration useEffect run.
|
|
await act(async () => {
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
});
|
|
|
|
const controller = ttsControllerInstances[0] as {
|
|
addEventListener: { mock: { calls: [string, (e: Event) => void][] } };
|
|
};
|
|
const calls = controller.addEventListener.mock.calls;
|
|
const entry = calls.find(([name]) => name === 'tts-highlight-mark');
|
|
if (!entry) throw new Error('tts-highlight-mark listener was not registered');
|
|
return entry[1];
|
|
};
|
|
|
|
it('navigates to the cfi via view.goTo when TTS crosses into a new section', async () => {
|
|
const handler = await setupAndCaptureHighlightHandler();
|
|
|
|
// primaryIndex is 0 (current view section). Make the TTS cfi resolve to section 1.
|
|
mockView.resolveCFI.mockReturnValue({ index: 1, anchor: () => new Range() });
|
|
|
|
await act(async () => {
|
|
handler(new CustomEvent('tts-highlight-mark', { detail: { cfi: 'epubcfi(/6/8!/4/2)' } }));
|
|
});
|
|
|
|
expect(mockView.goTo).toHaveBeenCalledWith('epubcfi(/6/8!/4/2)');
|
|
expect(mockView.renderer.scrollToAnchor).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps in-section behaviour: scrolls via renderer without navigating', async () => {
|
|
const handler = await setupAndCaptureHighlightHandler();
|
|
|
|
mockView.resolveCFI.mockReturnValue({ index: 0, anchor: () => new Range() });
|
|
|
|
await act(async () => {
|
|
handler(new CustomEvent('tts-highlight-mark', { detail: { cfi: 'epubcfi(/6/4!/4/2)' } }));
|
|
});
|
|
|
|
expect(mockView.renderer.scrollToAnchor).toHaveBeenCalledTimes(1);
|
|
expect(mockView.goTo).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('useTTSControl background session lifecycle', () => {
|
|
type ControllerMock = {
|
|
shutdown: ReturnType<typeof vi.fn>;
|
|
detachView: ReturnType<typeof vi.fn>;
|
|
attachView: ReturnType<typeof vi.fn>;
|
|
terminated: boolean;
|
|
state: string;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
ttsControllerInstances.length = 0;
|
|
pendingInitResolvers.length = 0;
|
|
mockSessionManager.claim.mockClear();
|
|
mockSessionManager.detach.mockClear();
|
|
mockSessionManager.release.mockClear();
|
|
mockSessionManager.adopt.mockClear();
|
|
mockSessionManager.stopActive.mockClear();
|
|
mockSessionManager.getSessionByHash.mockReturnValue(null);
|
|
mockSessionManager.getActiveSession.mockReturnValue(null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
const startSession = async () => {
|
|
render(<Harness />);
|
|
await act(async () => {
|
|
const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
|
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!();
|
|
await p;
|
|
});
|
|
return ttsControllerInstances[0] as ControllerMock;
|
|
};
|
|
|
|
it('claims the session at controller birth', async () => {
|
|
await startSession();
|
|
expect(mockSessionManager.claim).toHaveBeenCalledWith(
|
|
'book-1',
|
|
ttsControllerInstances[0],
|
|
expect.objectContaining({ bookKey: 'book-1', title: 'T' }),
|
|
);
|
|
});
|
|
|
|
it('unmount while the session lives transfers ownership (detach, no shutdown)', async () => {
|
|
const controller = await startSession();
|
|
controller.state = 'playing';
|
|
controller.terminated = false;
|
|
mockSessionManager.getSessionByHash.mockReturnValue({
|
|
bookHash: 'book',
|
|
bookKey: 'book-1',
|
|
controller,
|
|
});
|
|
cleanup(); // unmounts the hook
|
|
expect(mockSessionManager.detach).toHaveBeenCalledWith('book');
|
|
expect(controller.shutdown).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('unmount after termination shuts down and releases', async () => {
|
|
const controller = await startSession();
|
|
controller.terminated = true;
|
|
mockSessionManager.getSessionByHash.mockReturnValue({
|
|
bookHash: 'book',
|
|
bookKey: 'book-1',
|
|
controller,
|
|
});
|
|
cleanup();
|
|
expect(controller.shutdown).toHaveBeenCalled();
|
|
expect(mockSessionManager.release).toHaveBeenCalledWith('book');
|
|
});
|
|
|
|
it('tts-close-book detaches a live session; tts-stop stays a hard stop', async () => {
|
|
const controller = await startSession();
|
|
controller.terminated = false;
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-close-book', { bookKey: 'book-1' });
|
|
});
|
|
expect(mockSessionManager.detach).toHaveBeenCalledWith('book');
|
|
expect(controller.shutdown).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
await eventDispatcher.dispatch('tts-stop', { bookKey: 'book-1' });
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
});
|
|
expect(controller.shutdown).toHaveBeenCalled();
|
|
expect(mockSessionManager.release).toHaveBeenCalledWith('book');
|
|
});
|
|
|
|
it('mounting a book stops an active session of a different, unmounted book', async () => {
|
|
mockSessionManager.getActiveSession.mockReturnValue({
|
|
bookHash: 'otherhash',
|
|
bookKey: 'otherhash-r9',
|
|
controller: {},
|
|
});
|
|
render(<Harness />);
|
|
await act(async () => {
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
});
|
|
expect(mockSessionManager.stopActive).toHaveBeenCalledWith('replaced');
|
|
});
|
|
|
|
it('adopts a live session for the same book without constructing a controller', async () => {
|
|
const liveController = {
|
|
state: 'playing',
|
|
terminated: false,
|
|
isViewAttached: false,
|
|
shutdown: vi.fn(),
|
|
detachView: vi.fn(),
|
|
attachView: vi.fn().mockResolvedValue(undefined),
|
|
getSpeakingLang: vi.fn().mockReturnValue('en'),
|
|
getCurrentHighlightCfi: vi.fn().mockReturnValue(null),
|
|
getSpokenSentence: vi.fn().mockReturnValue(null),
|
|
updateHighlightOptions: vi.fn(),
|
|
setHighlightGranularity: vi.fn(),
|
|
getVoiceId: vi.fn().mockReturnValue(''),
|
|
setTargetLang: vi.fn(),
|
|
setLang: vi.fn(),
|
|
setRate: vi.fn(),
|
|
pause: vi.fn().mockResolvedValue(true),
|
|
resume: vi.fn().mockResolvedValue(true),
|
|
start: vi.fn().mockResolvedValue(undefined),
|
|
stop: vi.fn().mockResolvedValue(undefined),
|
|
forward: vi.fn().mockResolvedValue(undefined),
|
|
backward: vi.fn().mockResolvedValue(undefined),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
redispatchPosition: vi.fn(),
|
|
};
|
|
mockSessionManager.getSessionByHash.mockReturnValue({
|
|
bookHash: 'book',
|
|
bookKey: 'book-old',
|
|
controller: liveController,
|
|
});
|
|
render(<Harness />);
|
|
await act(async () => {
|
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
});
|
|
expect(ttsControllerInstances).toHaveLength(0); // no new controller
|
|
expect(mockSessionManager.adopt).toHaveBeenCalledWith(
|
|
'book-1',
|
|
expect.objectContaining({ bookKey: 'book-1' }),
|
|
);
|
|
expect(liveController.attachView).toHaveBeenCalledWith(
|
|
mockView,
|
|
expect.objectContaining({ bookKey: 'book-1' }),
|
|
);
|
|
});
|
|
});
|