// tests/main/services/voice-conversation-redteam-r2-1.test.ts // VoiceConversationService 회귀 테스트: // - 오류 알림이 리스너 없는 'error' 로 던져져 세션이 'thinking' 에 마이크가 꺼진 채 굳던 버그 // - 텍스트 턴이 마이크를 멈추지 않고 다시 start 해 캡처 참조 카운트가 쌓이고 세션을 끝내도 마이크가 열려 있던 버그 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { getVoiceConversationService, resetVoiceConversationServiceForTests, } from '../../../src/main/services/VoiceConversationService' const mocks = vi.hoisted(() => ({ audioHandlers: new Map void>(), refCount: 0, audioStart: vi.fn(), audioStop: vi.fn(), transcribe: vi.fn(), chatStream: vi.fn(), speakSentences: vi.fn(async () => undefined), send: vi.fn(), play: vi.fn(), })) vi.mock('../../../src/main/services/LoggerService', () => ({ getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })) // AudioCaptureService 의 참조 카운트 의미를 흉내 낸다: start 는 +1, stop 은 -1 vi.mock('../../../src/main/services/AudioCaptureService', () => ({ getAudioCaptureService: () => ({ start: mocks.audioStart, stop: mocks.audioStop, on: (event: string, handler: (payload: { buffer: Buffer }) => void) => { mocks.audioHandlers.set(event, handler) }, off: (event: string) => { mocks.audioHandlers.delete(event) }, }), })) vi.mock('../../../src/main/services/LocalSTTService', () => ({ getLocalSTTService: () => ({ initialize: vi.fn(async () => undefined), transcribe: mocks.transcribe, }), })) vi.mock('../../../src/main/services/PremiumLLMService', () => ({ getPremiumLLMService: () => ({ cancelGeneration: vi.fn(), isAvailable: () => false }), })) vi.mock('../../../src/main/services/LocalLLMService', () => ({ getLocalLLMService: () => ({ isAvailable: () => true, chatStream: mocks.chatStream }), })) vi.mock('../../../src/main/services/TTSPlaybackService', () => ({ getTTSPlaybackService: () => ({ speakSentences: mocks.speakSentences, stop: vi.fn() }), })) vi.mock('../../../src/main/services/SoundEffectService', () => ({ getSoundEffectService: () => ({ play: mocks.play }), })) vi.mock('../../../src/main/services/ConfigService', () => ({ configGet: () => undefined, })) vi.mock('../../../src/main/services/LicenseService', () => ({ getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }), })) vi.mock('../../../src/main/windows/WindowManager', () => ({ getMainWindow: () => ({ isDestroyed: () => false, webContents: { send: mocks.send } }), })) async function* answer(text: string): AsyncGenerator { yield text return text } async function* failing(): AsyncGenerator { throw new Error('ollama down') } function feedAudio(bytes: number): void { const handler = mocks.audioHandlers.get('audio-data') if (!handler) throw new Error('audio-data handler not bound') handler({ buffer: Buffer.alloc(bytes) }) } function conversationErrors(): unknown[] { return mocks.send.mock.calls .filter(([channel]) => channel === IPC_CHANNELS.VOICE_CONVERSATION.ERROR) .map(([, payload]) => payload) } describe('VoiceConversationService — 오류 경로가 세션을 굳히지 않는다', () => { beforeEach(() => { resetVoiceConversationServiceForTests() vi.clearAllMocks() mocks.audioHandlers.clear() mocks.refCount = 0 mocks.audioStart.mockImplementation(async () => { mocks.refCount++ }) mocks.audioStop.mockImplementation(async () => { mocks.refCount = Math.max(0, mocks.refCount - 1) }) mocks.chatStream.mockImplementation(() => answer('네.')) }) afterEach(() => { resetVoiceConversationServiceForTests() }) it('0.5초보다 짧게 말하고 멈추면 던지지 않고 listening 으로 돌아가 다시 듣는다', async () => { const service = getVoiceConversationService() await service.startSession() feedAudio(100) await expect(service.finishListening()).resolves.toBeUndefined() expect(service.state).toBe('listening') expect(mocks.refCount).toBe(1) expect(conversationErrors()).toEqual([expect.objectContaining({ phase: 'stt' })]) expect(mocks.play).toHaveBeenCalledWith('error') }) it('음성이 감지되지 않으면(VAD 빈 결과) listening 으로 돌아가고 오류는 한 번만 알린다', async () => { mocks.transcribe.mockResolvedValue({ text: ' ' }) const service = getVoiceConversationService() await service.startSession() feedAudio(32_000) await service.finishListening() expect(service.state).toBe('listening') expect(conversationErrors()).toHaveLength(1) }) it('LLM 이 실패하면 listening 으로 돌아가고 llm 오류 하나만 알린다(stt 로 다시 알리지 않는다)', async () => { mocks.transcribe.mockResolvedValue({ text: '안녕' }) mocks.chatStream.mockImplementation(() => failing()) const service = getVoiceConversationService() await service.startSession() feedAudio(32_000) await expect(service.finishListening()).resolves.toBeUndefined() expect(service.state).toBe('listening') expect(conversationErrors()).toEqual([expect.objectContaining({ phase: 'llm' })]) expect(mocks.refCount).toBe(1) }) }) describe('VoiceConversationService — 마이크 참조 카운트 짝', () => { beforeEach(() => { resetVoiceConversationServiceForTests() vi.clearAllMocks() mocks.audioHandlers.clear() mocks.refCount = 0 mocks.audioStart.mockImplementation(async () => { mocks.refCount++ }) mocks.audioStop.mockImplementation(async () => { mocks.refCount = Math.max(0, mocks.refCount - 1) }) mocks.chatStream.mockImplementation(() => answer('네.')) }) it('startSession → 텍스트 두 번 → stopSession 뒤 캡처 참조가 0 이다(마이크가 닫힌다)', async () => { const service = getVoiceConversationService() await service.startSession() await service.sendTextMessage('첫 번째') await service.sendTextMessage('두 번째') expect(service.state).toBe('listening') expect(mocks.refCount).toBe(1) service.stopSession() await Promise.resolve() expect(mocks.refCount).toBe(0) expect(mocks.audioStart.mock.calls.length).toBe(mocks.audioStop.mock.calls.length) }) it('텍스트 턴 도중 취소해도 start/stop 짝이 맞는다', async () => { let release: () => void = () => undefined mocks.chatStream.mockImplementation(async function* (_m: unknown, options?: { signal?: AbortSignal }) { await new Promise((resolve) => { release = resolve options?.signal?.addEventListener('abort', () => resolve(), { once: true }) }) return '' }) const service = getVoiceConversationService() await service.startSession() const turn = service.sendTextMessage('질문') await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalled()) service.cancelResponse() release() await turn expect(service.state).toBe('listening') expect(mocks.refCount).toBe(1) service.stopSession() await Promise.resolve() expect(mocks.refCount).toBe(0) }) })