158 lines
6.3 KiB
TypeScript
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()
|
|
})
|
|
})
|