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
131
apps/desktop/tests/main/services/llm-gateway.test.ts
Normal file
131
apps/desktop/tests/main/services/llm-gateway.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// tests/main/services/llm-gateway.test.ts
|
||||
// LLM 백엔드 선택 정책(단일 정본)의 local / online / 불가 / 실패 매트릭스.
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
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() }),
|
||||
}))
|
||||
|
||||
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', model: 'gemma' })),
|
||||
chatStream: vi.fn(() => tokens('l')),
|
||||
cancelGeneration: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function gateway(backend: string | undefined, p: PremiumLlmAdapter, l: LocalLlmAdapter) {
|
||||
return createRoutedLlmGateway({
|
||||
getBackend: () => backend,
|
||||
loadPremium: async () => p,
|
||||
getLocal: () => l,
|
||||
})
|
||||
}
|
||||
|
||||
describe('LlmGateway 라우팅', () => {
|
||||
it('local 백엔드는 Premium 을 절대 건드리지 않는다', async () => {
|
||||
const p = premium()
|
||||
const l = local()
|
||||
const gw = gateway('local', p, l)
|
||||
|
||||
expect(await gw.processText('t', 'refine')).toBe('local')
|
||||
expect((await gw.generate('q', { systemPrompt: 'docs' })).text).toBe('local')
|
||||
expect(p.processText).not.toHaveBeenCalled()
|
||||
expect(p.generate).not.toHaveBeenCalled()
|
||||
expect(p.isAvailable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('online + 가용이면 Premium 을 쓴다', async () => {
|
||||
const p = premium()
|
||||
const l = local()
|
||||
const gw = gateway('online', p, l)
|
||||
expect(await gw.processText('t', 'custom', undefined, 'sys')).toBe('premium')
|
||||
expect(p.processText).toHaveBeenCalledWith('t', 'custom', undefined, 'sys')
|
||||
expect(l.processText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('online + 불가면 로컬로 폴백하고 사유를 알린다', async () => {
|
||||
const onFallback = vi.fn()
|
||||
const gw = gateway('online', premium({ isAvailable: vi.fn(() => false) }), local())
|
||||
expect(await gw.processText('t', 'refine', undefined, undefined, { onFallback })).toBe('local')
|
||||
expect(onFallback).toHaveBeenCalledWith(expect.stringContaining('unavailable'))
|
||||
})
|
||||
|
||||
it('online + Premium 호출 실패면 로컬로 폴백한다', async () => {
|
||||
const onFallback = vi.fn()
|
||||
const gw = gateway(
|
||||
'online',
|
||||
premium({ generate: vi.fn(async () => { throw new Error('quota') }) }),
|
||||
local(),
|
||||
)
|
||||
expect((await gw.generate('q', undefined, { onFallback })).text).toBe('local')
|
||||
expect(onFallback).toHaveBeenCalledWith(expect.stringContaining('quota'))
|
||||
})
|
||||
|
||||
it('로컬 실패는 그대로 올린다 (성공으로 위장하지 않는다)', async () => {
|
||||
const gw = gateway('local', premium(), local({ processText: vi.fn(async () => { throw new Error('ollama down') }) }))
|
||||
await expect(gw.processText('t', 'refine')).rejects.toThrow('ollama down')
|
||||
})
|
||||
|
||||
it('채팅 스트림: Premium 가용이면 Premium, 로컬은 옵션과 signal 을 받는다', async () => {
|
||||
const p = premium()
|
||||
const l = local()
|
||||
const signal = new AbortController().signal
|
||||
|
||||
const online = await gateway('online', p, l).openChatStream([{ role: 'user', content: 'hi' }], { signal })
|
||||
expect(online.backend).toBe('premium')
|
||||
|
||||
const offline = await gateway('local', p, l).openChatStream([{ role: 'user', content: 'hi' }], {
|
||||
signal,
|
||||
local: { maxTokens: 512, timeoutMs: 60000, keepAlive: '2m' },
|
||||
})
|
||||
expect(offline.backend).toBe('local')
|
||||
expect(l.chatStream).toHaveBeenCalledWith(
|
||||
[{ role: 'user', content: 'hi' }],
|
||||
{ maxTokens: 512, timeoutMs: 60000, keepAlive: '2m', signal },
|
||||
)
|
||||
})
|
||||
|
||||
it('채팅 스트림: 로컬이 불가하면 LLMServerUnreachable', async () => {
|
||||
const gw = gateway('local', premium(), local({ isAvailable: vi.fn(() => false) }))
|
||||
await expect(
|
||||
gw.openChatStream([], { signal: new AbortController().signal }),
|
||||
).rejects.toMatchObject({ code: ErrorCode.LLMServerUnreachable })
|
||||
})
|
||||
|
||||
it('cancel 은 로드된 두 어댑터를 모두 취소한다', async () => {
|
||||
const p = premium()
|
||||
const l = local()
|
||||
const gw = gateway('online', p, l)
|
||||
await gw.processText('t', 'refine')
|
||||
gw.cancel()
|
||||
expect(p.cancelGeneration).toHaveBeenCalled()
|
||||
expect(l.cancelGeneration).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue