171 lines
6.4 KiB
TypeScript
171 lines
6.4 KiB
TypeScript
// tests/main/services/stt-fallback-redteam-r1-3.test.ts
|
|
// 클라우드 STT 실패 → 초기화되지 않은 로컬 엔진 폴백이 영원히 끝나지 않던 버그의 회귀 테스트.
|
|
// - LocalSTTService.transcribe 는 모델이 없으면 스스로 초기화하고, 초기화가 실패하면 reject 한다
|
|
// - 동시에 들어온 두 호출이 서로를 덮어쓰지 않고, 각자의 옵션으로 전사된다
|
|
// - STTManager 폴백은 로컬이 실패하면 원래 클라우드 에러로 끝난다(무한 대기 없음)
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
|
import { getSTTManager, resetSTTManagerForTests } from '../../../src/main/services/stt/STTManager'
|
|
import {
|
|
getLocalSTTService,
|
|
resetLocalSTTServiceForTests,
|
|
} from '../../../src/main/services/LocalSTTService'
|
|
import type { TranscribeOptions, TranscriptionResult } from '../../../src/main/services/LocalSTTService'
|
|
import { initInMemoryConfig, resetInMemoryConfig, configSet } from '../../../src/main/services/ConfigService'
|
|
|
|
type LocalInternals = {
|
|
_modelReady: boolean
|
|
_sendToSidecar: (buf: Buffer, options?: TranscribeOptions) => Promise<TranscriptionResult>
|
|
}
|
|
|
|
function internals(): LocalInternals {
|
|
return getLocalSTTService() as unknown as LocalInternals
|
|
}
|
|
|
|
/** 일정 시간 안에 끝나지 않으면 실패시킨다 (무한 대기 회귀 감지) */
|
|
function settleWithin<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise<T>((_resolve, reject) => setTimeout(() => reject(new Error(`did not settle within ${ms}ms`)), ms)),
|
|
])
|
|
}
|
|
|
|
const echo = (buf: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> =>
|
|
Promise.resolve({
|
|
text: `${buf.length}:${options?.language ?? '-'}:${options?.initialPrompt ?? '-'}`,
|
|
segments: [],
|
|
language: options?.language ?? 'ko',
|
|
duration: 1,
|
|
processingTime: 1,
|
|
})
|
|
|
|
describe('LocalSTTService — 준비되지 않은 엔진의 transcribe', () => {
|
|
beforeEach(() => {
|
|
initInMemoryConfig()
|
|
resetLocalSTTServiceForTests()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
resetLocalSTTServiceForTests()
|
|
resetInMemoryConfig()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('초기화가 실패하면 대기하지 않고 그 에러로 reject 한다', async () => {
|
|
const local = getLocalSTTService()
|
|
vi.spyOn(local, 'initialize').mockRejectedValue(
|
|
new D3ROError(ErrorCode.STTSidecarSpawnFailed, 'no sidecar'),
|
|
)
|
|
|
|
await expect(settleWithin(local.transcribe(Buffer.alloc(32000)), 1000)).rejects.toMatchObject({
|
|
code: ErrorCode.STTSidecarSpawnFailed,
|
|
})
|
|
})
|
|
|
|
it('아무도 초기화하지 않았으면 스스로 초기화한 뒤 호출자 옵션으로 전사한다', async () => {
|
|
const local = getLocalSTTService()
|
|
const init = vi.spyOn(local, 'initialize').mockImplementation(async () => {
|
|
internals()._modelReady = true
|
|
})
|
|
vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo)
|
|
|
|
const result = await settleWithin(
|
|
local.transcribe(Buffer.alloc(100), { language: 'en', initialPrompt: 'hint' }),
|
|
1000,
|
|
)
|
|
|
|
expect(init).toHaveBeenCalledTimes(1)
|
|
expect(result.text).toBe('100:en:hint')
|
|
})
|
|
|
|
it('동시에 들어온 두 호출은 각자 결과를 받는다 (합쳐지거나 버려지지 않는다)', async () => {
|
|
const local = getLocalSTTService()
|
|
let release: () => void = () => undefined
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve
|
|
})
|
|
vi.spyOn(local, 'initialize').mockImplementation(async () => {
|
|
await gate
|
|
internals()._modelReady = true
|
|
})
|
|
vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo)
|
|
|
|
const first = local.transcribe(Buffer.alloc(10), { language: 'ko' })
|
|
const second = local.transcribe(Buffer.alloc(20), { language: 'ja' })
|
|
release()
|
|
|
|
const [a, b] = await settleWithin(Promise.all([first, second]), 1000)
|
|
expect(a.text).toBe('10:ko:-')
|
|
expect(b.text).toBe('20:ja:-')
|
|
})
|
|
})
|
|
|
|
describe('STTManager — 클라우드 실패 후 로컬 폴백', () => {
|
|
beforeEach(() => {
|
|
initInMemoryConfig()
|
|
resetSTTManagerForTests()
|
|
resetLocalSTTServiceForTests()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
vi.useRealTimers()
|
|
resetInMemoryConfig()
|
|
resetSTTManagerForTests()
|
|
resetLocalSTTServiceForTests()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
function failCloud(): void {
|
|
const mgr = getSTTManager()
|
|
mgr.setProvider('openai')
|
|
mgr.setProviderConfig('openai', { apiKey: 'sk-test' })
|
|
configSet('sttFallbackToLocal', true)
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({ ok: false, status: 503, text: async () => 'offline' }),
|
|
)
|
|
}
|
|
|
|
it('로컬 엔진 초기화가 실패하면 원래 클라우드 에러로 끝난다 (무한 대기 없음)', async () => {
|
|
failCloud()
|
|
vi.spyOn(getLocalSTTService(), 'initialize').mockRejectedValue(
|
|
new D3ROError(ErrorCode.STTModelLoadFailed, 'model missing'),
|
|
)
|
|
|
|
const outcome = settleWithin(getSTTManager().transcribe(Buffer.alloc(32000)), 2000)
|
|
await expect(outcome).rejects.toBeInstanceOf(Error)
|
|
await expect(outcome).rejects.not.toMatchObject({ message: expect.stringContaining('did not settle') })
|
|
})
|
|
|
|
it('로컬 폴백이 끝나지 않으면 상한 시간에 끊는다', async () => {
|
|
failCloud()
|
|
vi.spyOn(getLocalSTTService(), 'transcribe').mockReturnValue(new Promise<never>(() => undefined))
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
|
|
|
const { LOCAL_FALLBACK_TIMEOUT_MS } = await import('../../../src/main/services/stt/STTManager')
|
|
const pending = getSTTManager().transcribe(Buffer.alloc(32000))
|
|
const assertion = expect(pending).rejects.toBeInstanceOf(Error)
|
|
await vi.advanceTimersByTimeAsync(LOCAL_FALLBACK_TIMEOUT_MS + 1)
|
|
await assertion
|
|
})
|
|
|
|
it('ensureReady 는 로컬 공급자에서만 모델을 올린다', async () => {
|
|
const mgr = getSTTManager()
|
|
const init = vi.spyOn(getLocalSTTService(), 'initialize').mockResolvedValue(undefined)
|
|
|
|
mgr.setProvider('openai')
|
|
await mgr.ensureReady()
|
|
expect(init).not.toHaveBeenCalled()
|
|
expect(mgr.supportsPartial()).toBe(false)
|
|
expect(mgr.getMissingModel()).toBeNull()
|
|
|
|
mgr.setProvider('local')
|
|
await mgr.ensureReady()
|
|
expect(init).toHaveBeenCalledTimes(1)
|
|
expect(mgr.supportsPartial()).toBe(true)
|
|
})
|
|
})
|