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> { 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) }) })