Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
194 lines
6.2 KiB
TypeScript
194 lines
6.2 KiB
TypeScript
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([])
|
|
})
|
|
})
|