import { describe, it, expect, vi } from 'vitest' import { EventEmitter } from 'events' import { ErrorCode } from '@d3ro/core/errors' import { getVoiceConversationService } from '../../src/main/services/VoiceConversationService' import { getVoiceActionService } from '../../src/main/services/VoiceActionService' import { useRedHarness } from './harness' import { FX, USER_TEXT } from './fixtures' const audioBus = new EventEmitter() vi.mock('../../src/main/services/AudioCaptureService', () => ({ getAudioCaptureService: () => ({ start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined), on: (ev: string, fn: (...args: unknown[]) => void) => audioBus.on(ev, fn), off: (ev: string, fn: (...args: unknown[]) => void) => audioBus.off(ev, fn), }), calculateRMS: () => 0, })) vi.mock('../../src/main/services/LocalSTTService', () => ({ getLocalSTTService: () => ({ initialize: vi.fn(async () => undefined), transcribe: vi.fn(async () => ({ text: FX.STT_OK, segments: [], language: 'ko', duration: 1, processingTime: 1, })), getModels: () => [], }), resetLocalSTTServiceForTests: () => undefined, })) vi.mock('../../src/main/services/PremiumLLMService', () => ({ getPremiumLLMService: () => ({ processText: vi.fn(async () => FX.LLM_OK), generate: vi.fn(async () => ({ text: FX.LLM_OK })), chatStream: async function* () { yield FX.LLM_OK return FX.LLM_OK }, cancelGeneration: vi.fn(), isAvailable: () => false, }), resetPremiumLLMServiceForTests: () => undefined, })) vi.mock('../../src/main/services/LocalLLMService', () => ({ getLocalLLMService: () => ({ isAvailable: () => true, processText: vi.fn(async () => FX.LLM_OK), chatStream: async function* () { yield FX.LLM_OK return FX.LLM_OK }, cancelGeneration: vi.fn(), getStatus: () => ({ connectionState: 'connected', serverUrl: 'http://localhost:11434', activeModel: 'x', serverVersion: null, }), }), resetLocalLLMServiceForTests: () => undefined, })) vi.mock('../../src/main/services/TTSPlaybackService', () => ({ getTTSPlaybackService: () => ({ speak: vi.fn(async (text: string) => { if (!text.trim()) { const { D3ROError, ErrorCode } = await import('@d3ro/core/errors') throw new D3ROError(ErrorCode.TTSTextEmpty, 'Empty TTS text') } }), speakSentences: vi.fn(async () => undefined), stop: vi.fn(), isSpeaking: false, on: vi.fn(), off: vi.fn(), removeAllListeners: vi.fn(), }), resetTTSPlaybackServiceForTests: () => undefined, })) vi.mock('../../src/main/services/SoundEffectService', () => ({ getSoundEffectService: () => ({ play: vi.fn() }), })) vi.mock('../../src/main/services/LicenseService', () => ({ getLicenseService: () => ({ canUse: () => ({ allowed: true, reason: 'ok' }), promptUpgrade: vi.fn(), }), resetLicenseServiceForTests: () => undefined, })) useRedHarness() describe('유스케이스: 음성 대화 세션 / 텍스트 전송 / 히스토리', () => { it('초기 대화 상태는 idle 이다', () => { const svc = getVoiceConversationService() expect(svc.state).toBe('idle') expect(svc.isActive).toBe(false) expect(svc.getHistory()).toEqual([]) }) it('세션을 시작하면 listening 이다', async () => { const svc = getVoiceConversationService() await svc.startSession() expect(svc.isActive).toBe(true) expect(svc.state).toBe('listening') svc.stopSession() }) it('중복 시작은 ConversationSessionAlreadyActive 다', async () => { const svc = getVoiceConversationService() await svc.startSession() await expect(svc.startSession()).rejects.toMatchObject({ code: ErrorCode.ConversationSessionAlreadyActive, }) svc.stopSession() }) it('세션 없이 텍스트 전송은 ConversationNoActiveSession 다', async () => { await expect(getVoiceConversationService().sendTextMessage('hi')).rejects.toMatchObject({ code: ErrorCode.ConversationNoActiveSession, }) }) it('텍스트 메시지를 보내면 히스토리에 user 가 남는다', async () => { const svc = getVoiceConversationService() await svc.startSession() await svc.sendTextMessage(USER_TEXT.KO) const hist = svc.getHistory() expect(hist.some((m) => m.role === 'user' && m.content === USER_TEXT.KO)).toBe(true) svc.stopSession() }) it('히스토리를 비운다', async () => { const svc = getVoiceConversationService() await svc.startSession() await svc.sendTextMessage(USER_TEXT.EN) svc.clearHistory() expect(svc.getHistory()).toEqual([]) svc.stopSession() }) it('세션을 중지하면 idle 이다', async () => { const svc = getVoiceConversationService() await svc.startSession() svc.stopSession() expect(svc.isActive).toBe(false) expect(svc.state).toBe('idle') }) it('비활성 중지/취소는 던지지 않는다', () => { expect(() => getVoiceConversationService().stopSession()).not.toThrow() expect(() => getVoiceConversationService().cancelResponse()).not.toThrow() }) it('프리미엄 불가 시 로컬 LLM 경로로 텍스트가 히스토리에 남는다', async () => { const svc = getVoiceConversationService() await svc.startSession() await svc.sendTextMessage(USER_TEXT.KO) const hist = svc.getHistory() expect(hist.some((m) => m.role === 'user' && m.content === USER_TEXT.KO)).toBe(true) expect(hist.some((m) => m.role === 'assistant' && m.content === FX.LLM_OK)).toBe(true) svc.stopSession() }) }) describe('유스케이스: 보이스 액션', () => { it('초기 액션은 비활성이다', () => { expect(getVoiceActionService().isEnabled).toBe(false) }) it('활성 토글이 동작한다', () => { getVoiceActionService().setEnabled(true) expect(getVoiceActionService().isEnabled).toBe(true) getVoiceActionService().setEnabled(false) expect(getVoiceActionService().isEnabled).toBe(false) }) it('프리셋 목록이 비어 있지 않다', () => { expect(getVoiceActionService().getPresets().length).toBeGreaterThan(0) }) it('히스토리를 비운다', () => { getVoiceActionService().clearHistory() expect(getVoiceActionService().getHistory()).toEqual([]) }) })