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(), history: { back: vi.fn(), forward: vi.fn() }, tts: { from: vi.fn().mockReturnValue('hello'), start: vi.fn().mockReturnValue('hello'), 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, showTTSBar: 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, getView: () => mockView, getProgress: () => mockProgress, getViewSettings: () => mockViewSettings, setViewSettings: vi.fn(), setTTSEnabled: vi.fn(), }; const useReaderStore = () => store; useReaderStore.getState = () => store; return { useReaderStore }; }); vi.mock('@/store/bookDataStore', () => ({ useBookDataStore: () => ({ getBookData: () => mockBookData, }), })); 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) { Object.assign(this, { init: vi.fn().mockImplementation( () => new Promise((resolve) => { pendingInitResolvers.push(() => resolve()); }), ), initViewTTS: vi.fn().mockResolvedValue(undefined), updateHighlightOptions: 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(''), state: 'idle', addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), }); ttsControllerInstances.push(this); }), })); vi.mock('@/libs/mediaSession', () => ({ TauriMediaSession: class {}, })); vi.mock('@/utils/ssml', () => ({ genSSMLRaw: vi.fn((s: string) => `${s}`), parseSSMLLang: vi.fn(() => 'en'), })); vi.mock('@/utils/throttle', () => ({ throttle: unknown>(fn: T) => fn, })); vi.mock('@/utils/cfi', () => ({ isCfiInLocation: () => false, })); vi.mock('@/utils/misc', () => ({ getLocale: () => 'en', })); 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, }), })); vi.mock('@/app/reader/hooks/useTTSMediaSession', () => ({ useTTSMediaSession: () => ({ mediaSessionRef: { current: null }, unblockAudio: vi.fn(), releaseUnblockAudio: vi.fn(), initMediaSession: vi.fn().mockResolvedValue(undefined), deinitMediaSession: vi.fn().mockResolvedValue(undefined), }), })); // Imports must come AFTER vi.mock calls so they pick up the mocked modules. import { useTTSControl } from '@/app/reader/hooks/useTTSControl'; import { eventDispatcher } from '@/utils/event'; 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(); 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]); }); }); });