d3ro-voice/apps/desktop/tests/main/services/voice-conversation-redteam-r2-1.test.ts

210 lines
7.3 KiB
TypeScript

// 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<string, (payload: { buffer: Buffer }) => 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<string, string> {
yield text
return text
}
async function* failing(): AsyncGenerator<string, string> {
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<void>((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)
})
})