// tests/main/services/audio-capture-redteam-r2-7.test.ts // AudioCaptureService / 마이크 테스트 IPC 회귀 테스트: // - kill 된 옛 SoX 의 늦은 'close' 가 새 캡처를 오류로 끝내지 않는다 // - 마이크 테스트는 자기가 잡은 참조만 놓는다 (회의·자막의 캡처를 빼앗지 않는다) // - 정지 시 SoX 를 곧바로 죽이지 않고 꼬리 오디오와 잔여 프레임을 흘려보낸다 // - SoX 버퍼를 줄여(--buffer 1024) 청크 지연·손실을 줄인다 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { EventEmitter } from 'events' import { ipcMain } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' vi.mock('../../../src/main/services/LoggerService', () => ({ getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })) vi.mock('../../../src/main/services/ConfigService', () => ({ configGet: vi.fn(() => 'default'), configSet: vi.fn(), })) vi.mock('../../../src/main/utils/paths', () => ({ getSoxPath: () => 'sox', })) vi.mock('../../../src/main/windows/WindowManager', () => ({ getMainWindow: () => null, })) class FakeProcess extends EventEmitter { stdout = new EventEmitter() stderr = new EventEmitter() kill = vi.fn() } const spawned = vi.hoisted(() => ({ list: [] as unknown[], args: [] as string[][] })) vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal() return { ...actual, spawn: vi.fn((_cmd: string, args: string[]) => { const proc = new FakeProcess() spawned.list.push(proc) spawned.args.push(args) return proc }), } }) type Module = typeof import('../../../src/main/services/AudioCaptureService') let mod: Module const FRAME = 1920 // 60ms function proc(i: number): FakeProcess { return spawned.list[i] as FakeProcess } beforeEach(async () => { vi.resetModules() vi.useRealTimers() spawned.list = [] spawned.args = [] mod = await import('../../../src/main/services/AudioCaptureService') mod.resetAudioCaptureServiceForTests() }) afterEach(() => { vi.useRealTimers() }) describe('SoX 프로세스 수명', () => { it("정지 후 곧바로 재시작하면 옛 SoX 의 늦은 'close' 가 새 캡처를 죽이지 않는다", async () => { const audio = mod.getAudioCaptureService() const stopped: string[] = [] audio.on('stopped', ({ reason }) => stopped.push(reason)) await audio.start() await audio.stop() await audio.start() expect(spawned.list).toHaveLength(2) // 옛 프로세스의 'close' 가 kill 몇 ms 뒤 도착 proc(0).emit('close', null) proc(0).emit('error', new Error('late')) expect(audio.state).toBe('capturing') expect(proc(1).kill).not.toHaveBeenCalled() expect(stopped).toEqual(['manual']) }) it('SoX 인자에 --buffer 1024 가 들어간다', async () => { await mod.getAudioCaptureService().start() const args = spawned.args[0] const i = args.indexOf('--buffer') expect(i).toBeGreaterThanOrEqual(0) expect(args[i + 1]).toBe(String(mod.SOX_BUFFER_BYTES)) expect(mod.SOX_BUFFER_BYTES).toBe(1024) }) }) describe('정지 시 꼬리 오디오', () => { it('정지 중 도착한 청크와 60ms 미만 잔여 바이트를 흘려보낸 뒤 SoX 를 끝낸다', async () => { const audio = mod.getAudioCaptureService() const received: number[] = [] audio.on('audio-data', ({ buffer }) => received.push(buffer.length)) await audio.start() proc(0).stdout.emit('data', Buffer.alloc(FRAME + 100)) // 1 프레임 + 잔여 100 const stopping = audio.stop() expect(proc(0).kill).not.toHaveBeenCalled() // SoX 버퍼에 남아 있던 끝부분이 정지 직후 도착 proc(0).stdout.emit('data', Buffer.alloc(FRAME)) await stopping expect(proc(0).kill).toHaveBeenCalledTimes(1) // 1920 (첫 프레임) + 1920 (잔여 100 + 꼬리 1820) + 잔여 100 (마지막 부분 프레임) expect(received).toEqual([FRAME, FRAME, 100]) expect(received.reduce((a, b) => a + b, 0)).toBe(FRAME * 2 + 100) expect(audio.state).toBe('idle') }) it('꼬리 수집 중 새 소비자가 오면 정지를 취소하고 같은 SoX 로 이어 간다', async () => { const audio = mod.getAudioCaptureService() const stopped: string[] = [] audio.on('stopped', ({ reason }) => stopped.push(reason)) await audio.start() const stopping = audio.stop() await audio.start() await stopping expect(spawned.list).toHaveLength(1) expect(proc(0).kill).not.toHaveBeenCalled() expect(audio.state).toBe('capturing') expect(stopped).toEqual([]) await audio.stop() expect(proc(0).kill).toHaveBeenCalledTimes(1) expect(stopped).toEqual(['manual']) }) }) describe('캡처 임대(lease)', () => { it('release 는 멱등이고 다른 소비자의 참조를 깎지 않는다', async () => { const audio = mod.getAudioCaptureService() await audio.start() // 회의·자막이 잡은 참조 const lease = await audio.acquire() await lease.release() await lease.release() expect(audio.state).toBe('capturing') expect(proc(0).kill).not.toHaveBeenCalled() }) it('캡처가 죽었다 다시 시작됐으면 옛 임대의 release 는 새 캡처를 멈추지 않는다', async () => { const audio = mod.getAudioCaptureService() const lease = await audio.acquire() proc(0).emit('close', 1) // 장치 분리 expect(audio.state).toBe('error') await audio.start() // 다른 소비자가 새로 시작 await lease.release() expect(audio.state).toBe('capturing') expect(proc(1).kill).not.toHaveBeenCalled() }) }) describe('마이크 테스트 IPC', () => { function handler(channel: string): () => Promise { const call = vi.mocked(ipcMain.handle).mock.calls.find(([ch]) => ch === channel) if (!call) throw new Error(`handler not registered: ${channel}`) return call[1] as unknown as () => Promise } it('회의 캡처 중에 마이크 테스트를 해도 회의 캡처는 끊기지 않는다', async () => { vi.mocked(ipcMain.handle).mockClear() const { registerAudioHandlers } = await import('../../../src/main/ipc/audio-handlers') registerAudioHandlers() const audio = mod.getAudioCaptureService() // 회의(자막) 가 캡처를 잡고 있다 await audio.start() const meetingFrames: number[] = [] audio.on('audio-data', ({ buffer }) => meetingFrames.push(buffer.length)) // 설정 창에서 TEST → 곧바로 STOP await handler(IPC_CHANNELS.AUDIO.TEST_DEVICE)() await handler(IPC_CHANNELS.AUDIO.STOP_TEST)() await new Promise((r) => setTimeout(r, mod.TAIL_FLUSH_MS + 20)) // 옛 SoX 를 죽이거나 새로 띄우지 않았고 회의 오디오가 계속 들어온다 expect(spawned.list).toHaveLength(1) expect(proc(0).kill).not.toHaveBeenCalled() expect(audio.state).toBe('capturing') proc(0).stdout.emit('data', Buffer.alloc(FRAME)) expect(meetingFrames).toEqual([FRAME]) }) it('테스트가 돌고 있지 않을 때의 STOP 은 다른 소비자의 참조를 놓지 않는다', async () => { vi.mocked(ipcMain.handle).mockClear() const { registerAudioHandlers } = await import('../../../src/main/ipc/audio-handlers') registerAudioHandlers() const audio = mod.getAudioCaptureService() await audio.start() await handler(IPC_CHANNELS.AUDIO.STOP_TEST)() await new Promise((r) => setTimeout(r, mod.TAIL_FLUSH_MS + 20)) expect(audio.state).toBe('capturing') expect(proc(0).kill).not.toHaveBeenCalled() }) it('테스트 단독이면 STOP 으로 캡처가 끝난다', async () => { vi.mocked(ipcMain.handle).mockClear() const { registerAudioHandlers } = await import('../../../src/main/ipc/audio-handlers') registerAudioHandlers() const audio = mod.getAudioCaptureService() await handler(IPC_CHANNELS.AUDIO.TEST_DEVICE)() expect(audio.state).toBe('capturing') await handler(IPC_CHANNELS.AUDIO.STOP_TEST)() await vi.waitFor(() => expect(audio.state).toBe('idle')) expect(proc(0).kill).toHaveBeenCalledTimes(1) }) })