Files
life-echo/app-expo/tests/features/voice/use-player.test.tsx
Kevin ddc701f22d fix(voice): queue split TTS segments after pause without replacing track
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>
2026-05-15 17:25:44 +08:00

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);
});
});