fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -0,0 +1,158 @@
|
|||
// tests/main/services/caption-stop-failure-redteam-r2-1.test.ts
|
||||
// 정지 중 마지막 문장 인식이 실패해도 CaptionService 가 'stopping' 에 굳지 않는다:
|
||||
// - stop() 은 성공하고 상태는 'inactive', 마이크 캡처는 정확히 한 번 멈춘다
|
||||
// - 다시 start() 할 수 있다
|
||||
// - 실패는 던지지 않는 'failure' 이벤트로 알린다
|
||||
// + CaptionAudioSources 의 멱등 해제
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CaptionAudioSources } from '../../../src/main/services/caption/CaptionAudioSources'
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: 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]),
|
||||
configSet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||
getLocalSTTService: () => ({
|
||||
initialize: vi.fn(async () => undefined),
|
||||
ensureAuxModel: vi.fn(async () => undefined),
|
||||
transcribe: vi.fn(async () => {
|
||||
throw new Error('sidecar HTTP 500')
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const audio = vi.hoisted(() => ({ start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) }))
|
||||
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||
getAudioCaptureService: () => ({ ...audio, on: vi.fn(), off: vi.fn() }),
|
||||
calculateRMS: () => 0.1,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/SoundEffectService', () => ({
|
||||
getSoundEffectService: () => ({ play: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({ isAvailable: () => false, generate: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/HistoryService', () => ({
|
||||
getHistoryService: () => ({ create: vi.fn(() => ({ id: 'h' })) }),
|
||||
}))
|
||||
|
||||
// 트랙: flush 가 마지막 문장을 인식하려다 실패한다 — 실제 트랙처럼 deps.onError 로 보고한다
|
||||
const trackMode = vi.hoisted(() => ({ mode: 'report' as 'report' | 'reject' }))
|
||||
vi.mock('../../../src/main/services/caption/StreamingCaptionTrack', () => ({
|
||||
StreamingCaptionTrack: class {
|
||||
constructor(private readonly deps: { onError: (err: unknown) => void }) {}
|
||||
push(): void {}
|
||||
async tick(): Promise<void> {}
|
||||
async flush(): Promise<void> {
|
||||
if (trackMode.mode === 'reject') throw new Error('flush exploded')
|
||||
this.deps.onError(new Error('sidecar HTTP 500'))
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
type CaptionModule = typeof import('../../../src/main/services/CaptionService')
|
||||
let mod: CaptionModule
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
config.values = {}
|
||||
trackMode.mode = 'report'
|
||||
mod = await import('../../../src/main/services/CaptionService')
|
||||
mod.resetCaptionServiceForTests()
|
||||
})
|
||||
|
||||
describe('CaptionService.stop — flush 실패', () => {
|
||||
it('인식 실패(onError)가 있어도 정지·해제·초기화를 끝내고 다시 시작할 수 있다', async () => {
|
||||
const caption = mod.getCaptionService()
|
||||
const failures: string[] = []
|
||||
caption.on('failure', (err) => failures.push(err.message))
|
||||
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
|
||||
|
||||
await expect(caption.stop('meeting')).resolves.toBeNull()
|
||||
|
||||
expect(caption.getState()).toBe('inactive')
|
||||
expect(audio.stop).toHaveBeenCalledTimes(1)
|
||||
expect(failures).toHaveLength(1)
|
||||
|
||||
await expect(caption.start({ audioSource: 'mic', showOverlay: false })).resolves.toBeUndefined()
|
||||
expect(caption.getState()).toBe('active')
|
||||
})
|
||||
|
||||
it('failure 리스너가 없어도 던지지 않는다(예전엔 리스너 없는 error 가 stop 을 튕겼다)', async () => {
|
||||
const caption = mod.getCaptionService()
|
||||
await caption.start({ audioSource: 'mic', showOverlay: false })
|
||||
await expect(caption.stop()).resolves.toBeNull()
|
||||
expect(caption.getState()).toBe('inactive')
|
||||
expect(audio.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('flush 자체가 reject 해도 해제하고 inactive 가 된다', async () => {
|
||||
trackMode.mode = 'reject'
|
||||
const caption = mod.getCaptionService()
|
||||
await caption.start({ audioSource: 'mic', showOverlay: false })
|
||||
await expect(caption.stop()).resolves.toBeNull()
|
||||
expect(caption.getState()).toBe('inactive')
|
||||
expect(audio.stop).toHaveBeenCalledTimes(1)
|
||||
// 두 번째 정지는 아무것도 하지 않는다
|
||||
await expect(caption.stop()).resolves.toBeNull()
|
||||
expect(audio.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CaptionAudioSources', () => {
|
||||
function sources(options: { stopFails?: boolean } = {}) {
|
||||
const mic = {
|
||||
onChunk: vi.fn(() => unsubscribe),
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => {
|
||||
if (options.stopFails) throw new Error('sox gone')
|
||||
}),
|
||||
}
|
||||
const unsubscribe = vi.fn()
|
||||
const system = { requestStart: vi.fn(), requestStop: vi.fn() }
|
||||
const onReleaseError = vi.fn()
|
||||
const audioSources = new CaptionAudioSources({ mic: () => mic, system, onReleaseError })
|
||||
return { audioSources, mic, unsubscribe, system, onReleaseError }
|
||||
}
|
||||
|
||||
it('release 는 멱등이다 — 마이크 stop 은 start 한 만큼만(한 번) 부른다', async () => {
|
||||
const h = sources()
|
||||
await h.audioSources.acquire('both', { onMicChunk: vi.fn() })
|
||||
expect(h.mic.start).toHaveBeenCalledTimes(1)
|
||||
expect(h.system.requestStart).toHaveBeenCalledTimes(1)
|
||||
|
||||
await h.audioSources.release()
|
||||
await h.audioSources.release()
|
||||
|
||||
expect(h.mic.stop).toHaveBeenCalledTimes(1)
|
||||
expect(h.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(h.audioSources.holdsMic).toBe(false)
|
||||
})
|
||||
|
||||
it('마이크 해제가 실패해도 시스템 소리 중지 요청은 보낸다', async () => {
|
||||
const h = sources({ stopFails: true })
|
||||
await h.audioSources.acquire('both', { onMicChunk: vi.fn() })
|
||||
await expect(h.audioSources.release()).resolves.toBeUndefined()
|
||||
expect(h.onReleaseError).toHaveBeenCalledWith('mic', expect.any(Error))
|
||||
expect(h.system.requestStop).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('system 소스만 잡으면 마이크는 건드리지 않는다', async () => {
|
||||
const h = sources()
|
||||
await h.audioSources.acquire('system', { onMicChunk: vi.fn() })
|
||||
await h.audioSources.release()
|
||||
expect(h.mic.start).not.toHaveBeenCalled()
|
||||
expect(h.mic.stop).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue