fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -0,0 +1,158 @@
|
|||
// 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
// tests/main/services/file-transcription-cancel-redteam-r2-1.test.ts
|
||||
// 파일 전사 취소가 실행 중인 ffmpeg 를 곧바로 종료하고 서비스를 idle 로 돌린다.
|
||||
// 취소된 작업의 늦은 정리(상태 되돌리기·임시 폴더 삭제)가 다음 작업을 덮지 않는다.
|
||||
// + 드롭 경로·시작 실패 분류·늦은 응답 무시(렌더러 순수 모델)
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import {
|
||||
StartRequestTracker,
|
||||
classifyStartFailure,
|
||||
resolveDroppedFilePath,
|
||||
} from '../../../src/renderer/components/file-drop-model'
|
||||
|
||||
type FakeProcess = EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stderr: EventEmitter
|
||||
exitCode: number | null
|
||||
killed: boolean
|
||||
kill: () => boolean
|
||||
args: string[]
|
||||
}
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
spawn: vi.fn(),
|
||||
procs: [] as unknown[],
|
||||
/** true 면 WAV 변환(ffmpeg -i ... out.wav)이 끝나지 않고 매달린다 */
|
||||
hangConvert: true,
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({ spawn: hoisted.spawn }))
|
||||
vi.mock('../../../src/main/utils/paths', () => ({ getFfmpegPath: () => '/bundled/ffmpeg.exe' }))
|
||||
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ getRuntimeProvisioner: () => ({ ensure: vi.fn() }) }))
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({ configGet: vi.fn(() => undefined) }))
|
||||
vi.mock('../../../src/main/services/stt/STTManager', () => ({
|
||||
getSTTManager: () => ({ transcribe: vi.fn(async () => ({ text: 'hello', segments: [] })) }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/HistoryService', () => ({ getHistoryService: () => ({ create: vi.fn() }) }))
|
||||
vi.mock('../../../src/main/services/LicenseService', () => ({
|
||||
getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/windows/WindowManager', () => ({ getMainWindow: () => null }))
|
||||
|
||||
function fakeSpawn(_command: string, args: string[]): FakeProcess {
|
||||
const proc = Object.assign(new EventEmitter(), {
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
exitCode: null as number | null,
|
||||
killed: false,
|
||||
args,
|
||||
kill(): boolean {
|
||||
proc.killed = true
|
||||
queueMicrotask(() => proc.emit('close', null))
|
||||
return true
|
||||
},
|
||||
})
|
||||
hoisted.procs.push(proc)
|
||||
const isConvert = args[args.length - 1].endsWith('.wav') && !args.includes('null')
|
||||
queueMicrotask(() => {
|
||||
if (isConvert && hoisted.hangConvert) return
|
||||
if (args.includes('null')) proc.stderr.emit('data', Buffer.from('Duration: 00:00:10.00'))
|
||||
if (args.includes('pipe:1')) proc.stdout.emit('data', Buffer.from('pcm'))
|
||||
proc.exitCode = 0
|
||||
proc.emit('close', 0)
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
describe('FileTranscriptionService 취소', () => {
|
||||
let inputFile: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
hoisted.procs = []
|
||||
hoisted.hangConvert = true
|
||||
hoisted.spawn.mockImplementation(fakeSpawn)
|
||||
inputFile = path.join(os.tmpdir(), `d3ro-ft-cancel-${Date.now()}.mp4`)
|
||||
fs.writeFileSync(inputFile, 'x')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(inputFile, { force: true })
|
||||
})
|
||||
|
||||
it('변환 중 취소하면 ffmpeg 를 종료하고, 곧바로 idle 이 되어 새 작업을 받는다', async () => {
|
||||
const mod = await import('../../../src/main/services/FileTranscriptionService')
|
||||
mod.resetFileTranscriptionServiceForTests()
|
||||
const service = mod.getFileTranscriptionService()
|
||||
|
||||
const first = service.startTranscription(inputFile, 'en')
|
||||
await vi.waitFor(() => expect(hoisted.procs).toHaveLength(1))
|
||||
const convert = hoisted.procs[0] as FakeProcess
|
||||
const firstTempDir = path.dirname(convert.args[convert.args.length - 1])
|
||||
|
||||
service.cancel()
|
||||
|
||||
expect(convert.killed).toBe(true)
|
||||
expect(service.state).toBe('idle')
|
||||
await expect(first).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
|
||||
expect(fs.existsSync(firstTempDir)).toBe(false)
|
||||
|
||||
// 새 작업은 거부되지 않고 끝까지 간다
|
||||
hoisted.hangConvert = false
|
||||
const result = await service.startTranscription(inputFile, 'en')
|
||||
expect(result.fullText).toBe('hello')
|
||||
})
|
||||
|
||||
it('취소된 작업의 늦은 정리가 다음 작업의 상태·jobId 를 지우지 않는다', async () => {
|
||||
const mod = await import('../../../src/main/services/FileTranscriptionService')
|
||||
mod.resetFileTranscriptionServiceForTests()
|
||||
const service = mod.getFileTranscriptionService()
|
||||
|
||||
const first = service.startTranscription(inputFile, 'en')
|
||||
await vi.waitFor(() => expect(hoisted.procs).toHaveLength(1))
|
||||
service.cancel()
|
||||
|
||||
// 이전 작업이 정리되기 전에 새 작업을 시작한다(여전히 변환 중에 매달림)
|
||||
const second = service.startTranscription(inputFile, 'en')
|
||||
await expect(first).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
const info = service.getStateInfo()
|
||||
expect(info.state).toBe('converting')
|
||||
expect(info.jobId).not.toBeNull()
|
||||
|
||||
service.cancel()
|
||||
await expect(second).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
|
||||
expect(service.getStateInfo()).toMatchObject({ state: 'idle', jobId: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileDropZone 모델', () => {
|
||||
it('드롭한 파일 경로는 getPathForFile 로 얻고, 못 얻으면 null(대화상자로 떨어지지 않는다)', () => {
|
||||
const file = { name: 'meeting.mp3' } as File
|
||||
expect(resolveDroppedFilePath(file, () => 'C:\\rec\\meeting.mp3')).toBe('C:\\rec\\meeting.mp3')
|
||||
expect(resolveDroppedFilePath(file, () => '')).toBeNull()
|
||||
expect(
|
||||
resolveDroppedFilePath(file, () => {
|
||||
throw new Error('not a real file')
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('취소·대화상자 닫기는 오류로 보이지 않는다', () => {
|
||||
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionCancelled, message: 'Transcription cancelled' })).toBe('cancelled')
|
||||
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionCancelled, message: 'File selection cancelled' })).toBe('cancelled')
|
||||
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionFFmpegFailed, message: 'ffmpeg exited' })).toBe('error')
|
||||
})
|
||||
|
||||
it('취소·새 시작 뒤에는 이전 요청의 늦은 응답을 무시한다', () => {
|
||||
const tracker = new StartRequestTracker()
|
||||
const first = tracker.begin()
|
||||
tracker.invalidate()
|
||||
const second = tracker.begin()
|
||||
expect(tracker.isCurrent(first)).toBe(false)
|
||||
expect(tracker.isCurrent(second)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
// tests/main/services/llm-limits-routing-redteam-r2-1.test.ts
|
||||
// - Premium 단일 턴(generate/processText)은 프록시 한도를 넘으면 보내지 않고 LLMPromptTooLong
|
||||
// - 단일 턴도 호출 단위 signal 로 취소된다(전역 cancelGeneration 없이)
|
||||
// - 게이트웨이: 취소는 로컬로 폴백하지 않는다, 채팅 스트림에 signal 을 넘긴다
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
|
||||
import {
|
||||
createRoutedLlmGateway,
|
||||
type LocalLlmAdapter,
|
||||
type PremiumLlmAdapter,
|
||||
} from '../../../src/main/services/llm/LlmGateway'
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
const cloud = vi.hoisted(() => ({
|
||||
isEnabled: (): boolean => true,
|
||||
isAuthenticated: (): boolean => true,
|
||||
invokeFunctionStream: vi.fn(),
|
||||
invokeFunction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/CloudSyncService', () => ({
|
||||
getCloudSyncService: () => cloud,
|
||||
}))
|
||||
|
||||
import { getPremiumLLMService, resetPremiumLLMServiceForTests } from '../../../src/main/services/PremiumLLMService'
|
||||
|
||||
const okResponse = {
|
||||
data: {
|
||||
id: 'm', model: 'claude', role: 'assistant',
|
||||
content: [{ type: 'text', text: '결과' }],
|
||||
stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
|
||||
describe('PremiumLLMService 단일 턴 한도', () => {
|
||||
beforeEach(() => {
|
||||
resetPremiumLLMServiceForTests()
|
||||
cloud.invokeFunction.mockReset()
|
||||
cloud.invokeFunction.mockResolvedValue(okResponse)
|
||||
})
|
||||
|
||||
it('8,000자를 넘는 generate 입력은 보내지 않고 LLMPromptTooLong 이다', async () => {
|
||||
const text = 'x'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars + 1)
|
||||
await expect(getPremiumLLMService().generate(text)).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong })
|
||||
expect(cloud.invokeFunction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('system 이 한도를 넘어도 보내지 않는다', async () => {
|
||||
await expect(
|
||||
getPremiumLLMService().generate('q', { systemPrompt: 's'.repeat(LLM_PROXY_CHAT_LIMITS.maxSystemChars + 1) }),
|
||||
).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong })
|
||||
await expect(
|
||||
getPremiumLLMService().processText('y'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars + 1), 'refine'),
|
||||
).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong })
|
||||
expect(cloud.invokeFunction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('경계값(정확히 8,000자, 앞뒤 공백 제외)은 보낸다', async () => {
|
||||
const text = ` ${'x'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars)} `
|
||||
await expect(getPremiumLLMService().generate(text)).resolves.toEqual({ text: '결과' })
|
||||
expect(cloud.invokeFunction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('단일 턴도 호출 단위 signal 로 취소된다(cancelGeneration 없이)', async () => {
|
||||
cloud.invokeFunction.mockImplementation(
|
||||
(_n: string, _b: unknown, options?: { signal?: AbortSignal }) =>
|
||||
new Promise((resolve) => {
|
||||
options?.signal?.addEventListener('abort', () =>
|
||||
resolve({ data: null, error: { message: 'FunctionsFetchError: aborted' } }), { once: true })
|
||||
}),
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const pending = getPremiumLLMService().generate('q', { signal: controller.signal })
|
||||
controller.abort()
|
||||
await expect(pending).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
|
||||
})
|
||||
})
|
||||
|
||||
async function* tokens(...values: string[]): AsyncGenerator<string, string> {
|
||||
for (const v of values) yield v
|
||||
return values.join('')
|
||||
}
|
||||
|
||||
function premium(overrides: Partial<PremiumLlmAdapter> = {}): PremiumLlmAdapter {
|
||||
return {
|
||||
isAvailable: vi.fn(() => true),
|
||||
processText: vi.fn(async () => 'premium'),
|
||||
generate: vi.fn(async () => ({ text: 'premium' })),
|
||||
chatStream: vi.fn(() => tokens('p')),
|
||||
cancelGeneration: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function local(overrides: Partial<LocalLlmAdapter> = {}): LocalLlmAdapter {
|
||||
return {
|
||||
isAvailable: vi.fn(() => true),
|
||||
processText: vi.fn(async () => 'local'),
|
||||
generate: vi.fn(async () => ({ text: 'local' })),
|
||||
chatStream: vi.fn(() => tokens('l')),
|
||||
cancelGeneration: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('LlmGateway 호출 단위 취소', () => {
|
||||
it('Premium 호출이 취소되면 로컬로 폴백하지 않고 취소를 전파한다', async () => {
|
||||
const controller = new AbortController()
|
||||
const l = local()
|
||||
const p = premium({
|
||||
generate: vi.fn(async () => {
|
||||
controller.abort()
|
||||
throw Object.assign(new Error('cancelled'), { code: ErrorCode.LLMProcessingCancelled })
|
||||
}),
|
||||
})
|
||||
const gw = createRoutedLlmGateway({ getBackend: () => 'online', loadPremium: async () => p, getLocal: () => l })
|
||||
|
||||
await expect(gw.generate('q', undefined, { signal: controller.signal })).rejects.toBeTruthy()
|
||||
expect(l.generate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('signal 을 Premium 단일 턴·채팅 스트림에 넘기고, 다른 호출은 건드리지 않는다', async () => {
|
||||
const p = premium()
|
||||
const gw = createRoutedLlmGateway({ getBackend: () => 'online', loadPremium: async () => p, getLocal: () => local() })
|
||||
const a = new AbortController()
|
||||
const b = new AbortController()
|
||||
|
||||
await gw.processText('t', 'refine', undefined, undefined, { signal: a.signal })
|
||||
await gw.openChatStream([{ role: 'user', content: 'hi' }], { signal: b.signal })
|
||||
a.abort()
|
||||
|
||||
expect(p.processText).toHaveBeenCalledWith('t', 'refine', undefined, undefined, { signal: a.signal })
|
||||
expect(p.chatStream).toHaveBeenCalledWith([{ role: 'user', content: 'hi' }], { signal: b.signal })
|
||||
expect(b.signal.aborted).toBe(false)
|
||||
expect(p.cancelGeneration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('이미 취소된 signal 이면 어떤 백엔드도 부르지 않는다', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const l = local()
|
||||
const gw = createRoutedLlmGateway({ getBackend: () => 'local', loadPremium: async () => premium(), getLocal: () => l })
|
||||
await expect(gw.processText('t', 'refine', undefined, undefined, { signal: controller.signal })).rejects.toMatchObject({
|
||||
code: ErrorCode.LLMProcessingCancelled,
|
||||
})
|
||||
expect(l.processText).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
// tests/main/services/meeting-session-redteam-r2-1.test.ts
|
||||
// MeetingRecordingSession(가짜 포트)·전사 줄이기(map-reduce) 단위 테스트
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CaptionSegment } from '@d3ro/core/types'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import { MeetingRecordingSession } from '../../../src/main/services/meeting/MeetingRecordingSession'
|
||||
import { condenseTranscriptToBudget } from '../../../src/main/services/meeting/transcript-condenser'
|
||||
|
||||
function fakePorts() {
|
||||
let segmentListener: ((segment: CaptionSegment) => void) | null = null
|
||||
let chunkListener: ((buffer: Buffer) => void) | null = null
|
||||
let releaseStop: () => void = () => undefined
|
||||
const stop = vi.fn(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseStop = resolve
|
||||
})
|
||||
const last: CaptionSegment = { id: 'last', text: '마지막', timestamp: 3_000, isFinal: true }
|
||||
segmentListener?.(last)
|
||||
return { segments: [last] }
|
||||
})
|
||||
const unsubscribeSegment = vi.fn(() => {
|
||||
segmentListener = null
|
||||
})
|
||||
const ports = {
|
||||
caption: {
|
||||
onSegment: (listener: (segment: CaptionSegment) => void) => {
|
||||
segmentListener = listener
|
||||
return unsubscribeSegment
|
||||
},
|
||||
stop,
|
||||
},
|
||||
audio: {
|
||||
onChunk: (listener: (buffer: Buffer) => void) => {
|
||||
chunkListener = listener
|
||||
return () => {
|
||||
chunkListener = null
|
||||
}
|
||||
},
|
||||
},
|
||||
now: () => 10_000,
|
||||
}
|
||||
return {
|
||||
ports,
|
||||
stop,
|
||||
unsubscribeSegment,
|
||||
emitSegment: (segment: CaptionSegment) => segmentListener?.(segment),
|
||||
emitChunk: (buffer: Buffer) => chunkListener?.(buffer),
|
||||
releaseStop: () => releaseStop(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('MeetingRecordingSession', () => {
|
||||
it('동시에 두 번 멈춰도 자막 정지와 스냅샷은 한 번 — 같은 불변 스냅샷을 받는다', async () => {
|
||||
const h = fakePorts()
|
||||
const session = new MeetingRecordingSession('s1', 1_000, h.ports)
|
||||
session.attachCaption()
|
||||
session.attachAudio()
|
||||
h.emitSegment({ id: 'a', text: '첫 문장', timestamp: 2_000, isFinal: true })
|
||||
h.emitChunk(Buffer.from([1, 2]))
|
||||
|
||||
const first = session.stop()
|
||||
const second = session.stop()
|
||||
expect(session.isStopping).toBe(true)
|
||||
// 정지가 시작되면 메모·오디오는 더 받지 않는다
|
||||
expect(session.addMemo('m', '늦은 메모')).toBeNull()
|
||||
h.emitChunk(Buffer.from([3]))
|
||||
h.releaseStop()
|
||||
const [a, b] = await Promise.all([first, second])
|
||||
|
||||
expect(h.stop).toHaveBeenCalledTimes(1)
|
||||
expect(a).toBe(b)
|
||||
expect(a.segments.map((s) => s.text)).toEqual(['첫 문장', '마지막'])
|
||||
expect(a.durationMs).toBe(9_000)
|
||||
expect([...a.pcm]).toEqual([1, 2])
|
||||
expect(Object.isFrozen(a)).toBe(true)
|
||||
expect(h.unsubscribeSegment).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('자막 정지가 실패해도 그때까지의 세그먼트로 스냅샷을 만든다', async () => {
|
||||
const h = fakePorts()
|
||||
const onStopError = vi.fn()
|
||||
h.ports.caption.stop = vi.fn(async () => {
|
||||
throw new Error('stt 500')
|
||||
})
|
||||
const session = new MeetingRecordingSession('s2', 1_000, { ...h.ports, onStopError })
|
||||
session.attachCaption()
|
||||
h.emitSegment({ id: 'a', text: '남은 문장', timestamp: 2_000, isFinal: true })
|
||||
|
||||
const snapshot = await session.stop()
|
||||
|
||||
expect(onStopError).toHaveBeenCalled()
|
||||
expect(snapshot.segments.map((s) => s.text)).toEqual(['남은 문장'])
|
||||
})
|
||||
|
||||
it('checkpoint 는 멈추지 않고 전사만(오디오 없이) 준다', () => {
|
||||
const h = fakePorts()
|
||||
const session = new MeetingRecordingSession('s3', 1_000, h.ports)
|
||||
session.attachCaption()
|
||||
session.attachAudio()
|
||||
h.emitChunk(Buffer.from([9]))
|
||||
h.emitSegment({ id: 'a', text: '중간', timestamp: 2_000, isFinal: true })
|
||||
|
||||
const snapshot = session.checkpoint({ includeAudio: false })
|
||||
|
||||
expect(snapshot.segments).toHaveLength(1)
|
||||
expect(snapshot.pcm.length).toBe(0)
|
||||
expect(session.isStopping).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('condenseTranscriptToBudget (map-reduce)', () => {
|
||||
const transcript = Array.from({ length: 300 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i} `.padEnd(60, '가')).join('\n')
|
||||
|
||||
it('한도 안이면 LLM 을 부르지 않고 원문을 돌려준다', async () => {
|
||||
const llm = { generate: vi.fn() }
|
||||
const result = await condenseTranscriptToBudget(llm, '[00:01] 짧은 회의', 8_000)
|
||||
expect(result).toEqual({ text: '[00:01] 짧은 회의', condensed: false, rounds: 0 })
|
||||
expect(llm.generate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('한도를 넘으면 조각마다 줄이고, 각 조각 입력과 결과 모두 한도 안이다', async () => {
|
||||
const inputs: string[] = []
|
||||
const llm = {
|
||||
generate: vi.fn(async (text: string) => {
|
||||
inputs.push(text)
|
||||
return { text: `요약 ${inputs.length}` }
|
||||
}),
|
||||
}
|
||||
const result = await condenseTranscriptToBudget(llm, transcript, 8_000)
|
||||
expect(transcript.length).toBeGreaterThan(8_000)
|
||||
expect(result.condensed).toBe(true)
|
||||
expect(result.text.length).toBeLessThanOrEqual(8_000)
|
||||
expect(inputs.length).toBeGreaterThan(1)
|
||||
for (const input of inputs) expect(input.length).toBeLessThanOrEqual(8_000)
|
||||
expect(result.text).toContain(`1/${inputs.length}`)
|
||||
})
|
||||
|
||||
it('여러 번 줄여도 넘으면 조용히 자르지 않고 LLMPromptTooLong', async () => {
|
||||
const llm = { generate: vi.fn(async (text: string) => ({ text })) }
|
||||
await expect(condenseTranscriptToBudget(llm, transcript, 8_000, { maxRounds: 1 })).rejects.toMatchObject({
|
||||
code: ErrorCode.LLMPromptTooLong,
|
||||
})
|
||||
})
|
||||
|
||||
it('남은 자리가 없으면(메모가 한도를 다 쓰면) LLMPromptTooLong', async () => {
|
||||
await expect(condenseTranscriptToBudget({ generate: vi.fn() }, 'x', 0)).rejects.toMatchObject({
|
||||
code: ErrorCode.LLMPromptTooLong,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
// tests/main/services/voice-conversation-redteam-r2-1.test.ts
|
||||
// VoiceConversationService 회귀 테스트:
|
||||
// - 오류 알림이 리스너 없는 'error' 로 던져져 세션이 'thinking' 에 마이크가 꺼진 채 굳던 버그
|
||||
// - 텍스트 턴이 마이크를 멈추지 않고 다시 start 해 캡처 참조 카운트가 쌓이고 세션을 끝내도 마이크가 열려 있던 버그
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import {
|
||||
getVoiceConversationService,
|
||||
resetVoiceConversationServiceForTests,
|
||||
} from '../../../src/main/services/VoiceConversationService'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audioHandlers: new Map<string, (payload: { buffer: Buffer }) => void>(),
|
||||
refCount: 0,
|
||||
audioStart: vi.fn(),
|
||||
audioStop: vi.fn(),
|
||||
transcribe: vi.fn(),
|
||||
chatStream: vi.fn(),
|
||||
speakSentences: vi.fn(async () => undefined),
|
||||
send: vi.fn(),
|
||||
play: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
// AudioCaptureService 의 참조 카운트 의미를 흉내 낸다: start 는 +1, stop 은 -1
|
||||
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||
getAudioCaptureService: () => ({
|
||||
start: mocks.audioStart,
|
||||
stop: mocks.audioStop,
|
||||
on: (event: string, handler: (payload: { buffer: Buffer }) => void) => {
|
||||
mocks.audioHandlers.set(event, handler)
|
||||
},
|
||||
off: (event: string) => {
|
||||
mocks.audioHandlers.delete(event)
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||
getLocalSTTService: () => ({
|
||||
initialize: vi.fn(async () => undefined),
|
||||
transcribe: mocks.transcribe,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/PremiumLLMService', () => ({
|
||||
getPremiumLLMService: () => ({ cancelGeneration: vi.fn(), isAvailable: () => false }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({ isAvailable: () => true, chatStream: mocks.chatStream }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/TTSPlaybackService', () => ({
|
||||
getTTSPlaybackService: () => ({ speakSentences: mocks.speakSentences, stop: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/SoundEffectService', () => ({
|
||||
getSoundEffectService: () => ({ play: mocks.play }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: () => undefined,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LicenseService', () => ({
|
||||
getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/windows/WindowManager', () => ({
|
||||
getMainWindow: () => ({ isDestroyed: () => false, webContents: { send: mocks.send } }),
|
||||
}))
|
||||
|
||||
async function* answer(text: string): AsyncGenerator<string, string> {
|
||||
yield text
|
||||
return text
|
||||
}
|
||||
|
||||
async function* failing(): AsyncGenerator<string, string> {
|
||||
throw new Error('ollama down')
|
||||
}
|
||||
|
||||
function feedAudio(bytes: number): void {
|
||||
const handler = mocks.audioHandlers.get('audio-data')
|
||||
if (!handler) throw new Error('audio-data handler not bound')
|
||||
handler({ buffer: Buffer.alloc(bytes) })
|
||||
}
|
||||
|
||||
function conversationErrors(): unknown[] {
|
||||
return mocks.send.mock.calls
|
||||
.filter(([channel]) => channel === IPC_CHANNELS.VOICE_CONVERSATION.ERROR)
|
||||
.map(([, payload]) => payload)
|
||||
}
|
||||
|
||||
describe('VoiceConversationService — 오류 경로가 세션을 굳히지 않는다', () => {
|
||||
beforeEach(() => {
|
||||
resetVoiceConversationServiceForTests()
|
||||
vi.clearAllMocks()
|
||||
mocks.audioHandlers.clear()
|
||||
mocks.refCount = 0
|
||||
mocks.audioStart.mockImplementation(async () => {
|
||||
mocks.refCount++
|
||||
})
|
||||
mocks.audioStop.mockImplementation(async () => {
|
||||
mocks.refCount = Math.max(0, mocks.refCount - 1)
|
||||
})
|
||||
mocks.chatStream.mockImplementation(() => answer('네.'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetVoiceConversationServiceForTests()
|
||||
})
|
||||
|
||||
it('0.5초보다 짧게 말하고 멈추면 던지지 않고 listening 으로 돌아가 다시 듣는다', async () => {
|
||||
const service = getVoiceConversationService()
|
||||
await service.startSession()
|
||||
feedAudio(100)
|
||||
|
||||
await expect(service.finishListening()).resolves.toBeUndefined()
|
||||
|
||||
expect(service.state).toBe('listening')
|
||||
expect(mocks.refCount).toBe(1)
|
||||
expect(conversationErrors()).toEqual([expect.objectContaining({ phase: 'stt' })])
|
||||
expect(mocks.play).toHaveBeenCalledWith('error')
|
||||
})
|
||||
|
||||
it('음성이 감지되지 않으면(VAD 빈 결과) listening 으로 돌아가고 오류는 한 번만 알린다', async () => {
|
||||
mocks.transcribe.mockResolvedValue({ text: ' ' })
|
||||
const service = getVoiceConversationService()
|
||||
await service.startSession()
|
||||
feedAudio(32_000)
|
||||
|
||||
await service.finishListening()
|
||||
|
||||
expect(service.state).toBe('listening')
|
||||
expect(conversationErrors()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('LLM 이 실패하면 listening 으로 돌아가고 llm 오류 하나만 알린다(stt 로 다시 알리지 않는다)', async () => {
|
||||
mocks.transcribe.mockResolvedValue({ text: '안녕' })
|
||||
mocks.chatStream.mockImplementation(() => failing())
|
||||
const service = getVoiceConversationService()
|
||||
await service.startSession()
|
||||
feedAudio(32_000)
|
||||
|
||||
await expect(service.finishListening()).resolves.toBeUndefined()
|
||||
|
||||
expect(service.state).toBe('listening')
|
||||
expect(conversationErrors()).toEqual([expect.objectContaining({ phase: 'llm' })])
|
||||
expect(mocks.refCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('VoiceConversationService — 마이크 참조 카운트 짝', () => {
|
||||
beforeEach(() => {
|
||||
resetVoiceConversationServiceForTests()
|
||||
vi.clearAllMocks()
|
||||
mocks.audioHandlers.clear()
|
||||
mocks.refCount = 0
|
||||
mocks.audioStart.mockImplementation(async () => {
|
||||
mocks.refCount++
|
||||
})
|
||||
mocks.audioStop.mockImplementation(async () => {
|
||||
mocks.refCount = Math.max(0, mocks.refCount - 1)
|
||||
})
|
||||
mocks.chatStream.mockImplementation(() => answer('네.'))
|
||||
})
|
||||
|
||||
it('startSession → 텍스트 두 번 → stopSession 뒤 캡처 참조가 0 이다(마이크가 닫힌다)', async () => {
|
||||
const service = getVoiceConversationService()
|
||||
await service.startSession()
|
||||
await service.sendTextMessage('첫 번째')
|
||||
await service.sendTextMessage('두 번째')
|
||||
expect(service.state).toBe('listening')
|
||||
expect(mocks.refCount).toBe(1)
|
||||
|
||||
service.stopSession()
|
||||
await Promise.resolve()
|
||||
expect(mocks.refCount).toBe(0)
|
||||
expect(mocks.audioStart.mock.calls.length).toBe(mocks.audioStop.mock.calls.length)
|
||||
})
|
||||
|
||||
it('텍스트 턴 도중 취소해도 start/stop 짝이 맞는다', async () => {
|
||||
let release: () => void = () => undefined
|
||||
mocks.chatStream.mockImplementation(async function* (_m: unknown, options?: { signal?: AbortSignal }) {
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
options?.signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
})
|
||||
return ''
|
||||
})
|
||||
const service = getVoiceConversationService()
|
||||
await service.startSession()
|
||||
const turn = service.sendTextMessage('질문')
|
||||
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalled())
|
||||
service.cancelResponse()
|
||||
release()
|
||||
await turn
|
||||
|
||||
expect(service.state).toBe('listening')
|
||||
expect(mocks.refCount).toBe(1)
|
||||
service.stopSession()
|
||||
await Promise.resolve()
|
||||
expect(mocks.refCount).toBe(0)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue