import { act, renderHook } from '@testing-library/react-native'; import { audioFocus } from '@/core/audio/audio-focus'; import { usePlayer } from '@/features/voice/hooks/use-player'; const mockUseAudioPlayer = jest.fn(); const mockUseAudioPlayerStatus = jest.fn(); jest.mock('expo-audio', () => ({ useAudioPlayer: (...args: unknown[]) => mockUseAudioPlayer(...args), useAudioPlayerStatus: (...args: unknown[]) => mockUseAudioPlayerStatus(...args), })); jest.mock('@/core/audio/audio-focus', () => ({ audioFocus: { acquireForPlayback: jest.fn(), releaseIfOwnedBy: jest.fn(), onOwnerChange: jest.fn(() => jest.fn()), }, })); describe('usePlayer', () => { beforeEach(() => { mockUseAudioPlayer.mockReset(); mockUseAudioPlayerStatus.mockReset(); mockUseAudioPlayer.mockReturnValue({ pause: jest.fn(), play: jest.fn(), }); mockUseAudioPlayerStatus.mockReturnValue({ isLoaded: false, playing: false, currentTime: 0, duration: 0, }); jest.mocked(audioFocus.acquireForPlayback).mockResolvedValue(true); jest.mocked(audioFocus.releaseIfOwnedBy).mockResolvedValue(undefined); jest.mocked(audioFocus.onOwnerChange).mockImplementation(() => jest.fn()); }); test('keeps the native audio session active while app-level audio focus owns teardown', () => { renderHook(() => usePlayer()); expect(mockUseAudioPlayer).toHaveBeenCalledWith( null, expect.objectContaining({ downloadFirst: false, keepAudioSessionActive: true, }), ); }); test('pausePlayback toggles playing→paused and invokes native pause', async () => { mockUseAudioPlayerStatus.mockReturnValue({ isLoaded: true, playing: true, currentTime: 0.1, duration: 10, }); const pause = jest.fn(); const play = jest.fn(); mockUseAudioPlayer.mockReturnValue({ pause, play }); const { result } = renderHook(() => usePlayer()); await act(async () => { await result.current.enqueueExclusive({ uri: 'file:///fixture.mp3', kind: 'voice', }); }); expect(result.current.status).toBe('playing'); act(() => { result.current.pausePlayback(); }); expect(pause).toHaveBeenCalled(); expect(result.current.status).toBe('paused'); }); test('resumePlayback toggles paused→playing and invokes native play', async () => { mockUseAudioPlayerStatus.mockReturnValue({ isLoaded: true, playing: false, currentTime: 0.1, duration: 10, }); const pause = jest.fn(); const play = jest.fn(); mockUseAudioPlayer.mockReturnValue({ pause, play }); const { result } = renderHook(() => usePlayer()); await act(async () => { await result.current.enqueueExclusive({ uri: 'file:///fixture.mp3', kind: 'voice', }); }); act(() => { result.current.pausePlayback(); }); expect(result.current.status).toBe('paused'); await act(async () => { await result.current.resumePlayback(); }); expect(play).toHaveBeenCalled(); expect(result.current.status).toBe('playing'); }); test('pausePlayback is a no-op while idle', async () => { const pause = jest.fn(); mockUseAudioPlayer.mockReturnValue({ pause, play: jest.fn() }); const { result } = renderHook(() => usePlayer()); act(() => { result.current.pausePlayback(); }); expect(pause).not.toHaveBeenCalled(); expect(result.current.status).toBe('idle'); }); test('retries queued audio after acquire fails once then audio focus frees', async () => { const acquire = jest.mocked(audioFocus.acquireForPlayback); acquire.mockResolvedValueOnce(false).mockResolvedValue(true); let ownerListener: ((owner: null | string) => void) | undefined; jest.mocked(audioFocus.onOwnerChange).mockImplementation((cb) => { ownerListener = cb as (owner: null | string) => void; return jest.fn(); }); mockUseAudioPlayerStatus.mockReturnValue({ isLoaded: true, playing: false, currentTime: 0, duration: 10, }); const play = jest.fn(); mockUseAudioPlayer.mockReturnValue({ pause: jest.fn(), play }); const { result } = renderHook(() => usePlayer()); await act(async () => { await result.current.enqueue({ uri: 'file:///queued.mp3', kind: 'tts_auto', }); }); expect(acquire).toHaveBeenCalledTimes(1); expect(result.current.status).toBe('idle'); await act(async () => { ownerListener?.(null); }); expect(acquire).toHaveBeenCalledTimes(2); expect(play).toHaveBeenCalled(); }); test('after pause, new tts_auto clears backlog and kicks playNext', async () => { mockUseAudioPlayerStatus.mockReturnValue({ isLoaded: true, playing: false, currentTime: 0.1, duration: 10, }); const pause = jest.fn(); const play = jest.fn(); mockUseAudioPlayer.mockReturnValue({ pause, play }); const { result } = renderHook(() => usePlayer()); await act(async () => { await result.current.enqueue({ uri: 'file:///first.mp3', kind: 'tts_auto', }); }); expect(result.current.status).toBe('playing'); const playCountAfterFirst = play.mock.calls.length; act(() => { result.current.pausePlayback(); }); expect(result.current.status).toBe('paused'); await act(async () => { await result.current.enqueue({ uri: 'file:///latest.mp3', kind: 'tts_auto', }); }); expect(result.current.status).toBe('playing'); expect(play.mock.calls.length).toBeGreaterThan(playCountAfterFirst); expect(result.current.currentSource).toBe('file:///latest.mp3'); }); test('after pause, next uuid_seg tts_auto queues without replacing current (multi-segment TTS)', async () => { const aid = '78b32c06-d2f9-453b-9cc4-354e68fbcb2d'; mockUseAudioPlayerStatus.mockReturnValue({ isLoaded: true, playing: false, currentTime: 0.1, duration: 10, }); const pause = jest.fn(); const play = jest.fn(); mockUseAudioPlayer.mockReturnValue({ pause, play }); const { result } = renderHook(() => usePlayer()); await act(async () => { await result.current.enqueue({ uri: 'file:///seg0.mp3', kind: 'tts_auto', messageRef: { listKey: `${aid}_seg_0` }, }); }); expect(result.current.status).toBe('playing'); expect(result.current.currentSource).toBe('file:///seg0.mp3'); act(() => { result.current.pausePlayback(); }); expect(result.current.status).toBe('paused'); await act(async () => { await result.current.enqueue({ uri: 'file:///seg1.mp3', kind: 'tts_auto', messageRef: { listKey: `${aid}_seg_1` }, }); }); expect(result.current.status).toBe('paused'); expect(result.current.currentSource).toBe('file:///seg0.mp3'); expect(result.current.queueLength).toBe(1); }); });