refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes
This commit is contained in:
parent
0adedf5e7b
commit
83cf9133ac
39 changed files with 4787 additions and 1061 deletions
389
apps/desktop/tests/main/services/voice-mode-redteam-r1-3.test.ts
Normal file
389
apps/desktop/tests/main/services/voice-mode-redteam-r1-3.test.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
// tests/main/services/voice-mode-redteam-r1-3.test.ts
|
||||
// VoiceModeService 세션 런(SessionRun) 회귀 테스트:
|
||||
// - 취소된 세션의 늦은 STT 결과가 다음 세션에 삽입되지 않는다
|
||||
// - 지연 전사 중 두 번째 release 가 받아쓰기를 취소하지 않는다
|
||||
// - 오디오 캡처 참조는 세션당 정확히 한 번 놓는다 (start 대기 중 취소 포함)
|
||||
// - 녹음 중 캡처가 죽으면(장치 분리) 에러로 알린다
|
||||
// - 회의 모드가 소유한 자막은 자막 핫키로 끌 수 없다
|
||||
// - 표시 계층 이벤트(phase / insert-failed)
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { EventEmitter } from 'events'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService'
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (err: unknown) => void
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (err: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const result = (text: string): { text: string; segments: never[]; language: string; duration: number; processingTime: number } => ({
|
||||
text,
|
||||
segments: [],
|
||||
language: 'ko',
|
||||
duration: 1,
|
||||
processingTime: 1,
|
||||
})
|
||||
|
||||
const mockSTT = vi.hoisted(() => ({
|
||||
initialize: vi.fn(),
|
||||
transcribe: vi.fn(),
|
||||
transcribePartial: vi.fn(async () => ''),
|
||||
getModels: vi.fn(() => [
|
||||
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 },
|
||||
]),
|
||||
getStatus: vi.fn(() => ({ engineState: 'ready', activeModel: 'base', engineVersion: null, gpuAccelerated: false })),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||
getLocalSTTService: () => mockSTT,
|
||||
resetLocalSTTServiceForTests: () => undefined,
|
||||
}))
|
||||
|
||||
const audioBus = new EventEmitter()
|
||||
const mockAudio = vi.hoisted(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||
getAudioCaptureService: () => mockAudio,
|
||||
}))
|
||||
|
||||
const keyBinding = vi.hoisted(() => ({
|
||||
handler: null as ((payload: KeyBindingTriggerPayload) => void) | null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/KeyBindingService', () => ({
|
||||
getKeyBindingService: () => ({
|
||||
on: (_ev: string, fn: (payload: KeyBindingTriggerPayload) => void) => {
|
||||
keyBinding.handler = fn
|
||||
},
|
||||
off: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn((key: string) => config.values[key]),
|
||||
}))
|
||||
|
||||
const mockInsert = vi.hoisted(() => ({ insertText: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
||||
getTextInsertService: () => mockInsert,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({
|
||||
isAvailable: () => true,
|
||||
processText: vi.fn(async (text: string) => text),
|
||||
generate: vi.fn(),
|
||||
chatStream: vi.fn(),
|
||||
cancelGeneration: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const caption = vi.hoisted(() => ({
|
||||
state: 'inactive' as string,
|
||||
stop: vi.fn(),
|
||||
start: vi.fn(async () => undefined),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/CaptionService', () => ({
|
||||
getCaptionService: () => ({
|
||||
getState: () => caption.state,
|
||||
stop: caption.stop,
|
||||
start: caption.start,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/MeetingModeService', () => ({
|
||||
getMeetingModeService: () => ({ getState: () => 'recording' }),
|
||||
}))
|
||||
|
||||
type VoiceModeModule = typeof import('../../../src/main/services/VoiceModeService')
|
||||
let getVoiceModeService: VoiceModeModule['getVoiceModeService']
|
||||
|
||||
const SPEECH = Buffer.alloc(16000 * 2) // 1초
|
||||
|
||||
async function flush(times = 5): Promise<void> {
|
||||
for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r))
|
||||
}
|
||||
|
||||
/** Date.now 를 앞으로 당겨 accidental-press(700ms) 판정을 건너뛴다 */
|
||||
function advanceClock(ms: number): void {
|
||||
const base = Date.now()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(base + ms)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
audioBus.removeAllListeners()
|
||||
config.values = { sttModelId: 'base', defaultLLMAction: 'none', autoInsert: true, llmBackend: 'local' }
|
||||
caption.state = 'inactive'
|
||||
keyBinding.handler = null
|
||||
// once 큐가 테스트 사이로 새지 않게 완전히 초기화한다
|
||||
for (const fn of [mockSTT.initialize, mockSTT.transcribe, mockSTT.transcribePartial, mockSTT.getModels, mockAudio.start, mockAudio.stop, mockInsert.insertText, caption.stop]) {
|
||||
fn.mockReset()
|
||||
}
|
||||
mockSTT.initialize.mockResolvedValue(undefined)
|
||||
mockSTT.transcribe.mockResolvedValue(result('기본 전사'))
|
||||
mockSTT.transcribePartial.mockResolvedValue('')
|
||||
mockSTT.getModels.mockReturnValue([
|
||||
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 },
|
||||
])
|
||||
mockAudio.start.mockResolvedValue(undefined)
|
||||
mockAudio.stop.mockResolvedValue(undefined)
|
||||
mockAudio.on.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
|
||||
audioBus.on(ev, fn)
|
||||
})
|
||||
mockAudio.off.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
|
||||
audioBus.off(ev, fn)
|
||||
})
|
||||
mockInsert.insertText.mockResolvedValue({ success: true, method: 'clipboard', textLength: 1, durationMs: 1 })
|
||||
const mod = await import('../../../src/main/services/VoiceModeService')
|
||||
mod.resetVoiceModeServiceForTests()
|
||||
getVoiceModeService = mod.getVoiceModeService
|
||||
})
|
||||
|
||||
describe('세션 런 — 늦게 도착한 결과', () => {
|
||||
it('취소된 세션 A 의 STT 결과는 새 세션 B 에 삽입되지 않는다', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
const sttA = deferred<ReturnType<typeof result>>()
|
||||
mockSTT.transcribe.mockReturnValueOnce(sttA.promise)
|
||||
const completed: string[] = []
|
||||
svc.on('session-completed', ({ finalText }) => completed.push(finalText))
|
||||
|
||||
// 세션 A: 녹음 → 정지 → 전사 진행 중
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
const stopA = svc.stopSession()
|
||||
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
|
||||
|
||||
// A 취소 → B 시작
|
||||
svc.cancelSession()
|
||||
await svc.startSession('dictation')
|
||||
const sessionB = svc.currentSession
|
||||
expect(sessionB).not.toBeNull()
|
||||
|
||||
// A 의 결과가 늦게 도착
|
||||
sttA.resolve(result('세션 A 텍스트'))
|
||||
await stopA
|
||||
await flush()
|
||||
|
||||
expect(completed).toEqual([])
|
||||
expect(mockInsert.insertText).not.toHaveBeenCalled()
|
||||
expect(svc.currentSession?.id).toBe(sessionB?.id)
|
||||
expect(svc.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('취소된 세션의 늦은 STT 실패는 새 세션을 에러로 끝내지 않는다', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
const sttA = deferred<ReturnType<typeof result>>()
|
||||
mockSTT.transcribe.mockReturnValueOnce(sttA.promise)
|
||||
const errors: number[] = []
|
||||
svc.on('error', ({ error }) => errors.push(error.code))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
const stopA = svc.stopSession()
|
||||
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
|
||||
svc.cancelSession()
|
||||
await svc.startSession('dictation')
|
||||
|
||||
sttA.reject(new Error('sidecar exploded'))
|
||||
await stopA
|
||||
await flush()
|
||||
|
||||
expect(errors).toEqual([])
|
||||
expect(svc.isActive).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('세션 런 — 정지 멱등성', () => {
|
||||
it('지연 전사 중 두 번째 정지는 받아쓰기를 취소하지 않는다', async () => {
|
||||
const init = deferred<void>()
|
||||
mockSTT.initialize.mockReturnValueOnce(init.promise)
|
||||
const stt = deferred<ReturnType<typeof result>>()
|
||||
mockSTT.transcribe.mockReturnValueOnce(stt.promise)
|
||||
|
||||
const svc = getVoiceModeService()
|
||||
const cancelled: string[] = []
|
||||
const completed: string[] = []
|
||||
svc.on('session-cancelled', ({ reason }) => cancelled.push(reason))
|
||||
svc.on('session-completed', ({ finalText }) => completed.push(finalText))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
await svc.stopSession() // STT 미준비 → 대기
|
||||
|
||||
init.resolve() // 모델 로딩 완료 → 지연 전사 시작 (버퍼 비움)
|
||||
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
|
||||
|
||||
await svc.stopSession() // 두 번째 release
|
||||
expect(cancelled).toEqual([])
|
||||
|
||||
stt.resolve(result('살아남은 받아쓰기'))
|
||||
await vi.waitFor(() => expect(completed).toEqual(['살아남은 받아쓰기']))
|
||||
expect(mockInsert.insertText).toHaveBeenCalledWith('살아남은 받아쓰기', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('세션 런 — 오디오 참조 소유', () => {
|
||||
it('정지 후 "No speech" 에러가 나도 audio.stop 은 세션당 한 번이다', async () => {
|
||||
mockSTT.transcribe.mockResolvedValueOnce(result(' '))
|
||||
const svc = getVoiceModeService()
|
||||
const errors: number[] = []
|
||||
svc.on('error', ({ error }) => errors.push(error.code))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
await svc.stopSession()
|
||||
await vi.waitFor(() => expect(errors).toEqual([ErrorCode.STTNoAudioData]))
|
||||
expect(mockAudio.start).toHaveBeenCalledTimes(1)
|
||||
expect(mockAudio.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('audio.start 대기 중 취소돼도 늦게 잡힌 참조를 정확히 한 번 놓는다', async () => {
|
||||
const start = deferred<void>()
|
||||
mockAudio.start.mockReturnValueOnce(start.promise)
|
||||
const svc = getVoiceModeService()
|
||||
|
||||
const starting = svc.startSession('dictation')
|
||||
await vi.waitFor(() => expect(mockAudio.start).toHaveBeenCalledTimes(1))
|
||||
svc.cancelSession()
|
||||
expect(mockAudio.stop).not.toHaveBeenCalled()
|
||||
|
||||
start.resolve()
|
||||
await starting
|
||||
await flush()
|
||||
expect(mockAudio.stop).toHaveBeenCalledTimes(1)
|
||||
expect(audioBus.listenerCount('audio-data')).toBe(0)
|
||||
})
|
||||
|
||||
it('세션이 끝나면 오디오 핸들러가 모두 해제된다 (다음 세션 프레임 중복 없음)', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
await svc.stopSession()
|
||||
await vi.waitFor(() => expect(svc.isActive).toBe(false))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
expect(audioBus.listenerCount('audio-data')).toBe(1)
|
||||
expect(audioBus.listenerCount('stopped')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('캡처 손실', () => {
|
||||
it('오디오 없이 캡처가 죽으면 AudioCaptureFailed 에러를 낸다', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
const errors: number[] = []
|
||||
svc.on('error', ({ error }) => errors.push(error.code))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('stopped', { reason: 'device-lost' })
|
||||
await flush()
|
||||
|
||||
expect(errors).toEqual([ErrorCode.AudioCaptureFailed])
|
||||
expect(svc.isActive).toBe(false)
|
||||
// 캡처 쪽이 이미 정리했으므로 다른 소비자의 참조를 깎지 않는다
|
||||
expect(mockAudio.stop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("수동 정지('manual')는 캡처 손실로 보지 않는다", async () => {
|
||||
const svc = getVoiceModeService()
|
||||
const errors: number[] = []
|
||||
svc.on('error', ({ error }) => errors.push(error.code))
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('stopped', { reason: 'manual' })
|
||||
await flush()
|
||||
expect(errors).toEqual([])
|
||||
expect(svc.isActive).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('표시 이벤트', () => {
|
||||
it("모델 확인을 통과한 뒤에만 'recording' phase 를 내보낸다", async () => {
|
||||
mockSTT.getModels.mockReturnValueOnce([
|
||||
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: false, languages: [], accuracy: 2, speed: 4 },
|
||||
])
|
||||
const svc = getVoiceModeService()
|
||||
const phases: string[] = []
|
||||
const errors: number[] = []
|
||||
svc.on('phase', ({ phase }) => phases.push(phase))
|
||||
svc.on('error', ({ error }) => errors.push(error.code))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
|
||||
expect(phases).toEqual([])
|
||||
expect(errors).toEqual([ErrorCode.STTModelNotFound])
|
||||
})
|
||||
|
||||
it("녹음 → 정지에서 'recording' 다음 'thinking' phase 가 나간다", async () => {
|
||||
const svc = getVoiceModeService()
|
||||
const phases: string[] = []
|
||||
svc.on('phase', ({ phase }) => phases.push(phase))
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
await svc.stopSession()
|
||||
expect(phases).toEqual(['recording', 'thinking'])
|
||||
})
|
||||
|
||||
it('자동 삽입이 실패하면 insert-failed 를 내보낸다', async () => {
|
||||
mockInsert.insertText.mockRejectedValueOnce(new Error('clipboard busy'))
|
||||
const svc = getVoiceModeService()
|
||||
const failed: string[] = []
|
||||
svc.on('insert-failed', ({ text }) => failed.push(text))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
await svc.stopSession()
|
||||
await vi.waitFor(() => expect(failed).toEqual(['기본 전사']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('자막 핫키', () => {
|
||||
it('회의 모드가 소유한 자막 세션은 자막 핫키로 멈추지 않는다', async () => {
|
||||
caption.state = 'active'
|
||||
caption.stop.mockResolvedValue(false)
|
||||
const svc = getVoiceModeService()
|
||||
svc.connectKeyBindings()
|
||||
expect(keyBinding.handler).not.toBeNull()
|
||||
|
||||
keyBinding.handler?.({
|
||||
actionId: 'caption',
|
||||
type: 'pressed',
|
||||
timestamp: Date.now(),
|
||||
isDoublePress: false,
|
||||
holdMode: false,
|
||||
} as KeyBindingTriggerPayload)
|
||||
// 소유자('user')를 밝혀 요청하므로 CaptionService 가 거부할 수 있다
|
||||
await vi.waitFor(() => expect(caption.stop).toHaveBeenCalledWith('user'))
|
||||
expect(caption.start).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue