Some checks failed
ci / 정본·보안·린트·타입·테스트 (push) Failing after 50s
ci / 워크스페이스 빌드 검증 (push) Has been skipped
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 20s
ci / .NET API 서버 테스트 (push) Successful in 12s
deploy-site / deploy (push) Successful in 39s
ci / 모바일 린트·타입·Jest (push) Failing after 3h10m31s
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
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<string | null> => '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()
|
|
const mockPickAudioFile = jest.fn()
|
|
|
|
jest.mock('@react-navigation/native', () => ({
|
|
useNavigation: () => ({ goBack: jest.fn() }),
|
|
}))
|
|
jest.mock('react-native-safe-area-context', () => ({
|
|
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
|
}))
|
|
jest.mock('../src/components/ScreenHeader', () => ({ ScreenHeader: () => null }))
|
|
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', sidebar: 'sidebar', card: 'card', inset: 'inset' },
|
|
text: { primary: 'primary', muted: 'muted' },
|
|
border: { default: 'default' },
|
|
overlay: { soft: 'soft' },
|
|
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<Record<string, unknown>>) => <View>{children}</View>
|
|
return {
|
|
AppStatusBar: () => null,
|
|
Header: () => null,
|
|
Led: () => null,
|
|
WaveBars: () => null,
|
|
NativeThemeProvider: Passthrough,
|
|
PhosphorText: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
|
|
<Text {...props}>{children}</Text>
|
|
),
|
|
PhysicalButton: ({ label, onPress, disabled, testID }: {
|
|
label: string
|
|
onPress?: () => void
|
|
disabled?: boolean
|
|
testID?: string
|
|
}) => (
|
|
<Pressable testID={testID} disabled={disabled} onPress={onPress}>
|
|
<Text>{label}</Text>
|
|
</Pressable>
|
|
),
|
|
d3roNativeFonts: { sans: 'sans' },
|
|
d3roNativeControl: { h: 48 },
|
|
d3roNativeRadius: { button: 8 },
|
|
}
|
|
})
|
|
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: (...args: unknown[]) => mockPickAudioFile(...args),
|
|
}))
|
|
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
|
|
importRequest?: number
|
|
}
|
|
|
|
async function render(params: Params, setParams: (next: Params) => void): Promise<ReactTestRenderer> {
|
|
let renderer: ReactTestRenderer | undefined
|
|
await act(async () => {
|
|
renderer = create(
|
|
<RecordScreen
|
|
route={{ params }}
|
|
navigation={{ getParent: () => undefined, setParams }}
|
|
/>,
|
|
)
|
|
})
|
|
if (renderer === undefined) throw new Error('RecordScreen did not render')
|
|
return renderer
|
|
}
|
|
|
|
async function press(renderer: ReactTestRenderer, testID: string): Promise<void> {
|
|
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)
|
|
})
|
|
|
|
test('consumes each FAB import request exactly once', async () => {
|
|
mockPickAudioFile.mockRejectedValue(
|
|
new AudioPipelineError('cancelled', 'Picker cancelled'),
|
|
)
|
|
const setParams = jest.fn()
|
|
const params = { importRequest: 1700000000000 }
|
|
const renderer = await render(params, setParams)
|
|
|
|
await act(async () => {
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(setParams).toHaveBeenCalledWith({ importRequest: undefined })
|
|
expect(mockPickAudioFile).toHaveBeenCalledTimes(1)
|
|
|
|
await act(async () => {
|
|
renderer.update(
|
|
<RecordScreen
|
|
route={{ params }}
|
|
navigation={{ getParent: () => undefined, setParams }}
|
|
/>,
|
|
)
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(mockPickAudioFile).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|