mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
fix(mobile): migrate expo-av to expo-video and expo-audio
expo-av has no SDK 56 compatible release, and its EXEventEmitter.h interface was removed from expo-modules-core, so the iOS release archive fails compiling Pods/EXAV. Move video playback to expo-video's useVideoPlayer/VideoView, and audio playback and recording to expo-audio's createAudioPlayer/AudioModule.AudioRecorder, preserving existing controls, mute/loop/resize behavior, and the recording audio session configuration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5e28117662
commit
b8cb2addff
11 files changed
+524
-387
No files matched your search
@@ -1,31 +1,43 @@
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
import { Alert } from 'react-native';
|
||||
import { Audio } from 'expo-av';
|
||||
import { AudioModule, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio';
|
||||
|
||||
import { useAudioRecorder } from '@/hooks/useAudioRecorder';
|
||||
|
||||
// eslint-plugin-import's namespace rule cannot statically resolve the
|
||||
// `AudioRecorder` class exposed on the `AudioModule` native module instance.
|
||||
// Alias it once here so the mock constructor is only dereferenced this way
|
||||
// in a single, deliberately suppressed spot.
|
||||
// eslint-disable-next-line import/namespace
|
||||
const AudioRecorderMock = AudioModule.AudioRecorder as jest.Mock;
|
||||
|
||||
const makeRecording = (overrides: Record<string, unknown> = {}) => ({
|
||||
getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 2400 })),
|
||||
stopAndUnloadAsync: jest.fn(async () => undefined),
|
||||
getURI: jest.fn(() => 'file://recording.m4a'),
|
||||
setOnRecordingStatusUpdate: jest.fn(),
|
||||
setProgressUpdateInterval: jest.fn(),
|
||||
getStatus: jest.fn(() => ({ isRecording: true, durationMillis: 2400, canRecord: true, url: null })),
|
||||
prepareToRecordAsync: jest.fn(async () => undefined),
|
||||
record: jest.fn(),
|
||||
stop: jest.fn(async () => undefined),
|
||||
uri: 'file://recording.m4a',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useAudioRecorder', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
(Audio.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
|
||||
(Audio.setAudioModeAsync as jest.Mock).mockResolvedValue(undefined);
|
||||
(requestRecordingPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
|
||||
(setAudioModeAsync as jest.Mock).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('alerts and refuses to start without microphone permission', async () => {
|
||||
(Audio.requestPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
|
||||
(requestRecordingPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
|
||||
const { result } = renderHook(() => useAudioRecorder());
|
||||
|
||||
let started = true;
|
||||
@@ -36,21 +48,22 @@ describe('useAudioRecorder', () => {
|
||||
'Microphone Permission',
|
||||
'Please allow microphone access to record audio.'
|
||||
);
|
||||
expect(Audio.Recording.createAsync).not.toHaveBeenCalled();
|
||||
expect(AudioRecorderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts, reports duration, and returns the completed recording', async () => {
|
||||
const recording = makeRecording();
|
||||
(Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording });
|
||||
AudioRecorderMock.mockImplementation(() => recording);
|
||||
const onComplete = jest.fn();
|
||||
const { result } = renderHook(() => useAudioRecorder({ onRecordingComplete: onComplete }));
|
||||
|
||||
await act(async () => { expect(await result.current.startRecording()).toBe(true); });
|
||||
expect(result.current.state).toBe('recording');
|
||||
expect(recording.setProgressUpdateInterval).toHaveBeenCalledWith(100);
|
||||
expect(recording.prepareToRecordAsync).toHaveBeenCalled();
|
||||
expect(recording.record).toHaveBeenCalled();
|
||||
|
||||
const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0];
|
||||
act(() => statusCallback({ isRecording: true, durationMillis: 2400 }));
|
||||
// Duration is reported by polling the recorder status, not by a push event.
|
||||
await act(async () => { jest.advanceTimersByTime(100); });
|
||||
expect(result.current.durationSeconds).toBe(2);
|
||||
|
||||
let completed: unknown;
|
||||
@@ -66,50 +79,55 @@ describe('useAudioRecorder', () => {
|
||||
|
||||
it('auto-stops when the configured duration is reached', async () => {
|
||||
const recording = makeRecording({
|
||||
getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 1000 })),
|
||||
getStatus: jest.fn(() => ({ isRecording: true, durationMillis: 1000, canRecord: true, url: null })),
|
||||
});
|
||||
(Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording });
|
||||
AudioRecorderMock.mockImplementation(() => recording);
|
||||
const onComplete = jest.fn();
|
||||
const { result } = renderHook(() => useAudioRecorder({ maxDurationSeconds: 1, onRecordingComplete: onComplete }));
|
||||
|
||||
await act(async () => { await result.current.startRecording(); });
|
||||
const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0];
|
||||
await act(async () => { statusCallback({ isRecording: true, durationMillis: 1000 }); });
|
||||
await act(async () => { jest.advanceTimersByTime(100); });
|
||||
// Flush the microtasks queued by the auto-stop call.
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
|
||||
expect(recording.stopAndUnloadAsync).toHaveBeenCalled();
|
||||
expect(recording.stop).toHaveBeenCalled();
|
||||
expect(onComplete).toHaveBeenCalledWith('file://recording.m4a', 1);
|
||||
});
|
||||
|
||||
it('resets an active recording and cleans it up on unmount', async () => {
|
||||
const first = makeRecording();
|
||||
const second = makeRecording();
|
||||
(Audio.Recording.createAsync as jest.Mock)
|
||||
.mockResolvedValueOnce({ recording: first })
|
||||
.mockResolvedValueOnce({ recording: second });
|
||||
AudioRecorderMock
|
||||
.mockImplementationOnce(() => first)
|
||||
.mockImplementationOnce(() => second);
|
||||
const view = renderHook(() => useAudioRecorder());
|
||||
|
||||
await act(async () => { await view.result.current.startRecording(); });
|
||||
act(() => view.result.current.resetRecording());
|
||||
expect(first.stopAndUnloadAsync).toHaveBeenCalled();
|
||||
expect(first.stop).toHaveBeenCalled();
|
||||
expect(view.result.current.state).toBe('idle');
|
||||
|
||||
await act(async () => { await view.result.current.startRecording(); });
|
||||
view.unmount();
|
||||
expect(second.stopAndUnloadAsync).toHaveBeenCalled();
|
||||
expect(Audio.setAudioModeAsync).toHaveBeenLastCalledWith({
|
||||
allowsRecordingIOS: false,
|
||||
playsInSilentModeIOS: true,
|
||||
expect(second.stop).toHaveBeenCalled();
|
||||
expect(setAudioModeAsync).toHaveBeenLastCalledWith({
|
||||
allowsRecording: false,
|
||||
playsInSilentMode: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('recovers when starting or stopping throws', async () => {
|
||||
(Audio.Recording.createAsync as jest.Mock).mockRejectedValueOnce(new Error('native unavailable'));
|
||||
AudioRecorderMock.mockImplementationOnce(() => {
|
||||
throw new Error('native unavailable');
|
||||
});
|
||||
const first = renderHook(() => useAudioRecorder());
|
||||
await act(async () => { expect(await first.result.current.startRecording()).toBe(false); });
|
||||
expect(first.result.current.state).toBe('idle');
|
||||
|
||||
const recording = makeRecording({ getStatusAsync: jest.fn(async () => { throw new Error('lost'); }) });
|
||||
(Audio.Recording.createAsync as jest.Mock).mockResolvedValueOnce({ recording });
|
||||
const recording = makeRecording({
|
||||
getStatus: jest.fn(() => { throw new Error('lost'); }),
|
||||
});
|
||||
AudioRecorderMock.mockImplementationOnce(() => recording);
|
||||
const second = renderHook(() => useAudioRecorder());
|
||||
await act(async () => { await second.result.current.startRecording(); });
|
||||
await act(async () => { expect(await second.result.current.stopRecording()).toBeNull(); });
|
||||
|
||||
Reference in new issue
Block a user