fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -0,0 +1,210 @@
|
|||
// 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue