d3ro-voice/apps/desktop/tests/main/services/caption-stop-failure-redteam-r2-1.test.ts

158 lines
6.3 KiB
TypeScript

// 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()
})
})