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
392 lines
13 KiB
TypeScript
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)
|
|
})
|
|
})
|