d3ro-voice/apps/desktop/tests/red/voice.usecase.test.ts
Yun Chan 708e20f747
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
feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
2026-08-20 11:12:05 +09:00

392 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { EventEmitter } from 'events'
import { RecognitionState } from '@d3ro/core/types'
import { ErrorCode } from '@d3ro/core/errors'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { FX } from './fixtures'
import { invokeIpc, useRedHarness } from './harness'
const audioBus = new EventEmitter()
const mockAudio = {
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),
}
const mockSTT = {
initialize: vi.fn(async () => undefined),
transcribe: vi.fn(async () => ({
text: FX.STT_OK,
segments: [],
language: 'ko',
duration: 1,
processingTime: 10,
})),
getModels: vi.fn(() => [
{ id: 'large-v3-turbo', name: 'Turbo', sizeBytes: 0, downloaded: true, languages: [] },
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [] },
]),
getStatus: vi.fn(() => ({ engineState: 'ready', activeModel: 'large-v3-turbo', engineVersion: null, gpuAccelerated: false })),
}
const mockLLM = {
isAvailable: vi.fn(() => true),
processText: vi.fn(async () => FX.LLM_OK),
generate: vi.fn(async () => ({ text: FX.LLM_OK })),
cancelGeneration: vi.fn(),
on: vi.fn(),
off: vi.fn(),
}
const mockLicense = {
canUse: vi.fn(() => ({ allowed: true, reason: 'ok' })),
consumeQuota: vi.fn(),
promptUpgrade: vi.fn(),
initialize: vi.fn(),
getInfo: vi.fn(() => ({ tier: 'free' })),
on: vi.fn(),
}
const mockCaption = {
getState: vi.fn(() => 'inactive'),
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
on: vi.fn(),
off: vi.fn(),
}
vi.mock('../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => mockAudio,
calculateRMS: () => 0.1,
}))
vi.mock('../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => mockSTT,
resetLocalSTTServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({
isAvailable: vi.fn(() => true),
processText: (...args: unknown[]) => mockLLM.processText(...args),
chatStream: mockLLM.chatStream,
cancelGeneration: vi.fn(),
getStatus: () => ({
connectionState: 'connected',
serverUrl: 'http://localhost:11434',
activeModel: 'gemma4:e4b',
serverVersion: null,
}),
}),
resetLocalLLMServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: () => mockLLM,
resetPremiumLLMServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/TextInsertService', () => ({
getTextInsertService: () => ({
insertText: vi.fn(async () => ({ success: true, method: 'clipboard', textLength: 1, durationMs: 1 })),
}),
}))
vi.mock('../../src/main/services/LicenseService', () => ({
getLicenseService: () => mockLicense,
resetLicenseServiceForTests: () => undefined,
initLicenseService: () => undefined,
}))
vi.mock('../../src/main/services/CaptionService', () => ({
getCaptionService: () => mockCaption,
resetCaptionServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/HotkeyService', () => ({
getHotkeyService: () => ({
on: vi.fn(),
off: vi.fn(),
}),
}))
useRedHarness()
import { getVoiceModeService } from '../../src/main/services/VoiceModeService'
import { registerVoiceHandlers } from '../../src/main/ipc/voice-handlers'
import { configSet } from '../../src/main/services/ConfigService'
import { getHistoryService } from '../../src/main/services/HistoryService'
import {
persistCompletedVoiceSession,
persistCompletedVoiceSessionSafe,
} from '../../src/main/voice-session-persist'
import { unbindTestDatabase } from '../../src/main/db'
function feedAudio(): void {
audioBus.emit('audio-data', { buffer: Buffer.alloc(16000 * 2) })
}
async function startAndRecord(): Promise<ReturnType<typeof getVoiceModeService>> {
const svc = getVoiceModeService()
await svc.startSession('dictation')
feedAudio()
await new Promise((r) => setTimeout(r, 750))
feedAudio()
return svc
}
describe('유스케이스: 받아쓰기 시작/정지/취소 / STT·LLM 실패 표면화', () => {
beforeEach(() => {
mockSTT.initialize.mockReset()
mockSTT.transcribe.mockReset()
mockLLM.processText.mockReset()
mockSTT.initialize.mockResolvedValue(undefined)
mockSTT.transcribe.mockResolvedValue({
text: FX.STT_OK,
segments: [],
language: 'ko',
duration: 1,
processingTime: 10,
})
mockLLM.processText.mockResolvedValue(FX.LLM_OK)
mockLicense.canUse.mockReturnValue({ allowed: true, reason: 'ok' })
mockCaption.getState.mockReturnValue('inactive')
mockAudio.start.mockClear()
audioBus.removeAllListeners()
configSet('defaultLLMAction', 'refine')
configSet('autoInsert', false)
})
it('초기 음성 상태는 IDLE 이다', () => {
const state = getVoiceModeService().getState()
expect(state.recognitionState).toBe(RecognitionState.IDLE)
expect(state.sessionId).toBeNull()
expect(getVoiceModeService().isActive).toBe(false)
})
it('녹음 시작 시 세션이 활성화된다', async () => {
const svc = getVoiceModeService()
await svc.startSession('dictation')
expect(svc.isActive).toBe(true)
expect(svc.getState().sessionId).toBeTruthy()
})
it('사용자가 취소하면 session-cancelled(user) 가 난다', async () => {
const svc = getVoiceModeService()
let reason: string | null = null
svc.on('session-cancelled', (p: { reason: string }) => {
reason = p.reason
})
await svc.startSession('dictation')
svc.cancelSession()
expect(reason).toBe('user')
expect(svc.getState().recognitionState).toBe(RecognitionState.IDLE)
})
it('너무 짧은 녹음은 too-short 로 취소된다', async () => {
const svc = getVoiceModeService()
let reason: string | null = null
svc.on('session-cancelled', (p: { reason: string }) => {
reason = p.reason
})
await svc.startSession('dictation')
await svc.stopSession()
expect(reason).toBe('too-short')
})
it('정상 녹음 후 STT 픽스처가 전사로 올라온다', async () => {
const svc = await startAndRecord()
let finalText: string | null = null
svc.on('session-completed', (p: { finalText: string }) => {
finalText = p.finalText
})
await svc.stopSession()
expect(mockSTT.transcribe).toHaveBeenCalled()
expect(finalText === FX.LLM_OK || finalText === FX.STT_OK).toBe(true)
})
it('STT 가 빈 문자열이면 성공 완료가 아니라 error 이벤트다', async () => {
mockSTT.transcribe.mockResolvedValue({
text: FX.STT_EMPTY,
segments: [],
language: 'ko',
duration: 1,
processingTime: 1,
})
const svc = await startAndRecord()
const errors: Array<{ code: number }> = []
let completed = false
svc.on('error', (p: { error: { code: number } }) => errors.push(p.error))
svc.on('session-completed', () => {
completed = true
})
await svc.stopSession()
expect(completed).toBe(false)
expect(errors.some((e) => e.code === ErrorCode.STTNoAudioData)).toBe(true)
})
it('STT 가 공백만 반환해도 error 다', async () => {
mockSTT.transcribe.mockResolvedValue({
text: ' ',
segments: [],
language: 'ko',
duration: 1,
processingTime: 1,
})
const svc = await startAndRecord()
const errors: number[] = []
svc.on('error', (p: { error: { code: number } }) => errors.push(p.error.code))
await svc.stopSession()
expect(errors).toContain(ErrorCode.STTNoAudioData)
})
it('STT throw 는 STTTranscriptionFailed 로 표면화된다', async () => {
mockSTT.transcribe.mockRejectedValue(new Error('fx.stt.down'))
const svc = await startAndRecord()
const errors: number[] = []
svc.on('error', (p: { error: { code: number } }) => errors.push(p.error.code))
await svc.stopSession()
expect(errors).toContain(ErrorCode.STTTranscriptionFailed)
})
it('LLM 실패 시 원문으로 조용히 완료하지 않고 error 를 낸다', async () => {
mockLLM.processText.mockRejectedValue(new Error('fx.llm.timeout'))
const svc = await startAndRecord()
const errors: number[] = []
let completed = false
svc.on('error', (p: { error: { code: number } }) => errors.push(p.error.code))
svc.on('session-completed', () => {
completed = true
})
await svc.stopSession()
expect(completed).toBe(false)
expect(errors.length).toBeGreaterThan(0)
})
it('LLM 액션 none 이면 STT 픽스처가 최종 텍스트다', async () => {
configSet('defaultLLMAction', 'none')
const svc = await startAndRecord()
let finalText: string | null = null
svc.on('session-completed', (p: { finalText: string }) => {
finalText = p.finalText
})
await svc.stopSession()
expect(finalText).toBe(FX.STT_OK)
expect(mockLLM.processText).not.toHaveBeenCalled()
})
it('라이선스가 막으면 세션이 시작되지 않고 error 가 난다', async () => {
mockLicense.canUse.mockReturnValue({ allowed: false, reason: 'quota_exceeded' })
const svc = getVoiceModeService()
const errors: unknown[] = []
svc.on('error', (p) => errors.push(p))
await svc.startSession('dictation')
expect(svc.isActive).toBe(false)
expect(errors.length).toBeGreaterThan(0)
})
it('자막 모드가 켜져 있으면 받아쓰기를 시작하지 않고 error 다', async () => {
mockCaption.getState.mockReturnValue('active')
const svc = getVoiceModeService()
const errors: unknown[] = []
svc.on('error', (p) => errors.push(p))
await svc.startSession('dictation')
expect(svc.isActive).toBe(false)
expect(errors.length).toBeGreaterThan(0)
})
it('오디오 시작 실패는 AudioCaptureStartFailed 다', async () => {
mockAudio.start.mockRejectedValueOnce(new Error('fx.mic.denied'))
const svc = getVoiceModeService()
const errors: number[] = []
svc.on('error', (p: { error: { code: number } }) => errors.push(p.error.code))
await svc.startSession('dictation')
expect(errors).toContain(ErrorCode.AudioCaptureStartFailed)
})
it('중복 startSession 은 두 번째를 무시하고 기존 세션을 유지한다', async () => {
const svc = getVoiceModeService()
await svc.startSession('dictation')
const first = svc.getState().sessionId
await svc.startSession('dictation')
expect(svc.getState().sessionId).toBe(first)
})
it('IPC voice:getState 는 현재 상태를 돌려준다', async () => {
registerVoiceHandlers()
const res = await invokeIpc(IPC_CHANNELS.VOICE.GET_STATE)
expect(res.success).toBe(true)
if (res.success) expect(res.data.recognitionState).toBe(RecognitionState.IDLE)
})
it('IPC voice:startRecording 성공 시 sessionId 가 있다', async () => {
registerVoiceHandlers()
const res = await invokeIpc(IPC_CHANNELS.VOICE.START_RECORDING, { sessionId: 'ui' })
expect(res.success).toBe(true)
if (res.success) expect(res.data.sessionId).toBeTruthy()
})
it('IPC voice:cancelRecording 은 활성 세션을 종료한다', async () => {
registerVoiceHandlers()
await invokeIpc(IPC_CHANNELS.VOICE.START_RECORDING, { sessionId: 'ui' })
const res = await invokeIpc(IPC_CHANNELS.VOICE.CANCEL_RECORDING, { sessionId: 'ui' })
expect(res.success).toBe(true)
expect(getVoiceModeService().isActive).toBe(false)
})
it('hands-free 모드로도 세션을 시작할 수 있다', async () => {
const svc = getVoiceModeService()
await svc.startSession('hands-free')
expect(svc.currentSession?.mode).toBe('hands-free')
svc.cancelSession()
})
it('녹음 완료 → persistCompletedVoiceSession 이 히스토리 행을 만든다', async () => {
const svc = await startAndRecord()
svc.on('session-completed', ({ session, finalText }) => {
persistCompletedVoiceSession(session, finalText)
})
await svc.stopSession()
const page = getHistoryService().list({ page: 0, pageSize: 10 })
expect(page.total).toBe(1)
expect(page.entries[0].originalText === FX.STT_OK || page.entries[0].polishedText === FX.LLM_OK).toBe(
true,
)
expect(page.entries[0].status).toBe('completed')
})
it('히스토리 저장 실패는 삼키지 않고 error 로 표면화된다', async () => {
const svc = getVoiceModeService()
const errors: unknown[] = []
svc.on('error', (p) => errors.push(p))
persistCompletedVoiceSessionSafe(
(error, session) => {
svc.emit('error', { error, session })
},
{
id: 'fx-session',
transcription: FX.STT_OK,
processedText: null,
mode: 'dictation',
startedAt: Date.now(),
},
FX.STT_OK,
)
unbindTestDatabase()
persistCompletedVoiceSessionSafe(
(error, session) => {
svc.emit('error', { error, session })
},
{
id: 'fx-session-fail',
transcription: FX.STT_OK,
processedText: null,
mode: 'dictation',
startedAt: Date.now(),
},
FX.STT_OK,
)
expect(errors.length).toBeGreaterThan(0)
})
})