// 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 })) 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 {} async flush(): Promise { 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() }) })