Detect consecutive tts_auto items on the same assistant bubble via listKey (uuid_seg_n / uuid_part_n). When paused, skip the 'clear queue and play latest only' path so later segments enqueue instead of wiping playback. Add regression test. Co-authored-by: Cursor <cursoragent@cursor.com>
254 lines
6.9 KiB
TypeScript
254 lines
6.9 KiB
TypeScript
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);
|
|
});
|
|
});
|