import React from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import type { AudioPipelineResult, LocalAudioInput } from '../src/features/import/audio-import-types' const USER_ID = '11111111-1111-4111-8111-111111111111' const MEETING_ID = '22222222-2222-4222-8222-222222222222' const mockBeginMeetingRecording = jest.fn(async () => ({})) const mockCancelMeetingRecording = jest.fn(async () => ({})) const mockFailMeetingRecording = jest.fn(async () => ({})) const mockGetMeetingRecordingLanguage = jest.fn(async (): Promise => 'ko') const mockEnqueueAudioProcessing = jest.fn() const mockProcessQueuedAudioNow = jest.fn() const mockValidateLocalAudio = jest.fn() const mockCleanup = jest.fn(async () => undefined) const mockStart = jest.fn(async () => undefined) const mockStop = jest.fn() jest.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), })) jest.mock('@d3ro/i18n', () => ({ useI18n: () => ({ t: (key: string) => key, locale: 'ko' }), })) jest.mock('../src/lib/auth-context', () => ({ useAuth: () => ({ user: { id: '11111111-1111-4111-8111-111111111111' } }), })) jest.mock('../src/lib/preferences-context', () => ({ useMobilePreferences: () => ({ palette: { bg: { app: 'app', card: 'card', inset: 'inset' }, accent: { main: 'accent' }, tag: { red: 'red' }, }, }), })) jest.mock('@d3ro/ui-native', () => { const { Pressable, Text, View } = require('react-native') as typeof import('react-native') const Passthrough = ({ children }: React.PropsWithChildren>) => {children} return { AppStatusBar: () => null, Header: () => null, Led: () => null, WaveBars: () => null, NativeThemeProvider: Passthrough, PhosphorText: ({ children, ...props }: React.PropsWithChildren>) => ( {children} ), PhysicalButton: ({ label, onPress, disabled, testID }: { label: string onPress?: () => void disabled?: boolean testID?: string }) => ( {label} ), d3roNativeFonts: { mono: 'mono' }, } }) jest.mock('../src/lib/audio-recorder', () => { class RecorderBusyError extends Error {} const session = { restore: async () => ({ state: 'idle', recording: null, meetingId: null, interruptionReason: null }), start: () => mockStart(), stop: () => mockStop(), cleanup: () => mockCleanup(), cancel: async () => undefined, pause: async () => undefined, resume: async () => undefined, } return { RecorderBusyError, audioRecorder: { acquire: async () => session, requestPermission: async () => true, }, } }) jest.mock('../src/features/import/audio-validation', () => ({ validateLocalAudio: (options: unknown) => mockValidateLocalAudio(options), })) jest.mock('../src/features/import/audio-file-picker', () => ({ pickAudioFile: jest.fn() })) jest.mock('../src/features/import/incoming-media-intent', () => ({ consumeIncomingMediaAudio: jest.fn() })) jest.mock('../src/features/notifications/notification-native', () => ({ showLocalTranscriptionCompleted: jest.fn(async () => true), })) jest.mock('../src/features/recording/durable-processing-queue', () => { class QueuedAudioPendingError extends Error {} return { QueuedAudioPendingError, discardQueuedAudio: jest.fn(async () => undefined), enqueueAudioProcessing: (...args: unknown[]) => mockEnqueueAudioProcessing(...args), processQueuedAudioNow: (...args: unknown[]) => mockProcessQueuedAudioNow(...args), retryQueuedAudio: jest.fn(async () => undefined), subscribeToDurableQueue: () => () => undefined, } }) jest.mock('../src/features/meetings/meetings-service', () => ({ beginMeetingRecording: (...args: unknown[]) => mockBeginMeetingRecording(...(args as [])), cancelMeetingRecording: (...args: unknown[]) => mockCancelMeetingRecording(...(args as [])), failMeetingRecording: (...args: unknown[]) => mockFailMeetingRecording(...(args as [])), getMeetingRecordingLanguage: (...args: unknown[]) => mockGetMeetingRecordingLanguage(...(args as [])), })) import RecordScreen from '../src/screens/RecordScreen' import { AudioPipelineError } from '../src/features/import/audio-import-types' const RECORDING = { uri: 'file:///mock/capture.wav', path: '/mock/capture.wav', fileName: 'capture.wav', mimeType: 'audio/wav', size: 64, durationMs: 20 * 60_000, } function validInput(): LocalAudioInput { return { uri: RECORDING.uri, path: RECORDING.path, fileName: RECORDING.fileName, mimeType: 'audio/wav', sizeBytes: RECORDING.size, durationMs: RECORDING.durationMs, source: 'recording', dispose: async () => undefined, } } function meetingResult(): AudioPipelineResult { return { historyId: null, audioFileId: 'audio', meetingId: MEETING_ID, processingJobId: 'job', transcript: 'meeting transcript', provider: 'test', language: 'ko', durationSeconds: 1, deduplicated: false, } } type Params = { meetingId?: string; meetingTitle?: string; meetingLanguage?: string } async function render(params: Params, setParams: (next: Params) => void): Promise { let renderer: ReactTestRenderer | undefined await act(async () => { renderer = create( undefined, setParams }} />, ) }) if (renderer === undefined) throw new Error('RecordScreen did not render') return renderer } async function press(renderer: ReactTestRenderer, testID: string): Promise { await act(async () => { renderer.root.findByProps({ testID }).props.onPress() }) } function has(renderer: ReactTestRenderer, testID: string): boolean { return renderer.root.findAllByProps({ testID }).length > 0 } describe('RecordScreen meeting durability (redteam r2-27)', () => { beforeEach(() => { jest.clearAllMocks() mockStop.mockResolvedValue(RECORDING) mockValidateLocalAudio.mockImplementation(async () => validInput()) }) test('a hand-off that fails before persistence keeps the capture recoverable', async () => { mockEnqueueAudioProcessing.mockRejectedValue(new AudioPipelineError('file-read', 'disk full')) const renderer = await render({ meetingId: MEETING_ID, meetingLanguage: 'ko' }, jest.fn()) await press(renderer, 'record-start') await press(renderer, 'record-stop') expect(mockCleanup).not.toHaveBeenCalled() expect(has(renderer, 'record-process-recovered')).toBe(true) expect(has(renderer, 'record-discard-recovered')).toBe(true) }) test('a recording over the upload limit is kept instead of deleted on STOP', async () => { mockValidateLocalAudio.mockRejectedValue( new AudioPipelineError('file-too-large', 'Audio files must be 25 MB or smaller'), ) const renderer = await render({}, jest.fn()) await press(renderer, 'record-start') await press(renderer, 'record-stop') expect(mockCleanup).not.toHaveBeenCalled() expect(mockEnqueueAudioProcessing).not.toHaveBeenCalled() expect(has(renderer, 'record-process-recovered')).toBe(true) }) test('an offline meeting language lookup does not block the hand-off', async () => { mockGetMeetingRecordingLanguage.mockRejectedValueOnce(new TypeError('Network request failed')) mockEnqueueAudioProcessing.mockResolvedValue({ id: 'item' }) mockProcessQueuedAudioNow.mockResolvedValue(meetingResult()) const renderer = await render({ meetingId: MEETING_ID }, jest.fn()) await press(renderer, 'record-start') await press(renderer, 'record-stop') expect(mockEnqueueAudioProcessing).toHaveBeenCalledTimes(1) expect(has(renderer, 'record-transcript-result')).toBe(true) }) test('a finished meeting capture releases the binding so NEW RECORDING cannot restart it', async () => { mockEnqueueAudioProcessing.mockResolvedValue({ id: 'item' }) mockProcessQueuedAudioNow.mockResolvedValue(meetingResult()) const setParams = jest.fn() const renderer = await render({ meetingId: MEETING_ID, meetingLanguage: 'ko' }, setParams) await press(renderer, 'record-start') expect(mockBeginMeetingRecording).toHaveBeenCalledWith(USER_ID, MEETING_ID) await press(renderer, 'record-stop') expect(setParams).toHaveBeenCalledWith({ meetingId: undefined, meetingTitle: undefined, meetingLanguage: undefined, }) expect(has(renderer, 'record-view-meeting')).toBe(true) expect(has(renderer, 'record-meeting-context')).toBe(false) // The tab bar can bring the screen back with its old params still set. await press(renderer, 'record-start') expect(mockBeginMeetingRecording).toHaveBeenCalledTimes(1) expect(mockStart).toHaveBeenCalledTimes(2) }) })