refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes
This commit is contained in:
parent
0adedf5e7b
commit
83cf9133ac
39 changed files with 4787 additions and 1061 deletions
|
|
@ -0,0 +1,171 @@
|
|||
// 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue