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,75 @@
|
|||
// tests/main/services/audio-capture-redteam-r1-3.test.ts
|
||||
// SoX 가 장치를 못 열고 끝났을 때 'error' 리스너가 없어 ERR_UNHANDLED_ERROR 가 나고
|
||||
// 뒤이은 'stopped' 가 영영 나가지 않던 버그의 회귀 테스트.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
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(() => 'usb-headset-gone'),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/utils/paths', () => ({
|
||||
getSoxPath: () => 'sox',
|
||||
}))
|
||||
|
||||
class FakeProcess extends EventEmitter {
|
||||
stdout = new EventEmitter()
|
||||
stderr = new EventEmitter()
|
||||
kill = vi.fn()
|
||||
}
|
||||
|
||||
const spawned = vi.hoisted(() => ({ list: [] as unknown[] }))
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>()
|
||||
return {
|
||||
...actual,
|
||||
spawn: vi.fn(() => {
|
||||
const proc = new FakeProcess()
|
||||
spawned.list.push(proc)
|
||||
return proc
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
type Module = typeof import('../../../src/main/services/AudioCaptureService')
|
||||
let mod: Module
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
spawned.list = []
|
||||
mod = await import('../../../src/main/services/AudioCaptureService')
|
||||
mod.resetAudioCaptureServiceForTests()
|
||||
})
|
||||
|
||||
describe('AudioCaptureService — 캡처 프로세스 종료', () => {
|
||||
it("SoX 가 녹음 중 끝나도 예외 없이 'stopped'(device-lost) 를 알린다", async () => {
|
||||
const audio = mod.getAudioCaptureService()
|
||||
const stopped: string[] = []
|
||||
audio.on('stopped', ({ reason }) => stopped.push(reason))
|
||||
|
||||
await audio.start()
|
||||
expect(audio.state).toBe('capturing')
|
||||
|
||||
const proc = spawned.list[0] as FakeProcess
|
||||
expect(() => proc.emit('close', 1)).not.toThrow()
|
||||
|
||||
expect(stopped).toEqual(['device-lost'])
|
||||
expect(audio.state).toBe('error')
|
||||
})
|
||||
|
||||
it('실패 뒤에도 다시 시작할 수 있다', async () => {
|
||||
const audio = mod.getAudioCaptureService()
|
||||
await audio.start()
|
||||
;(spawned.list[0] as FakeProcess).emit('close', 1)
|
||||
|
||||
await audio.start()
|
||||
expect(audio.state).toBe('capturing')
|
||||
expect(spawned.list).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
// tests/main/services/caption-owner-redteam-r1-3.test.ts
|
||||
// CaptionService 세션 계약 회귀 테스트:
|
||||
// - 회의 모드가 소유한 자막 세션은 사용자 요청(stop('user'))으로 멈추지 않는다
|
||||
// - stop() 은 flush 중 확정된 마지막 문장까지 담은 요약을 돌려준다
|
||||
// - persistHistory:false 면 'caption' 히스토리를 만들지 않고 session-saved 도 없다
|
||||
// - 저장한 경우 요약에 히스토리 행 id 가 실린다 (자동 요약이 그 id 로 찾는다)
|
||||
// - audioSource 옵션은 전역 설정/다음 세션으로 새지 않는다
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
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((key: string, value: unknown) => {
|
||||
config.values[key] = value
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||
getLocalSTTService: () => ({
|
||||
initialize: vi.fn(async () => undefined),
|
||||
ensureAuxModel: vi.fn(async () => undefined),
|
||||
transcribe: vi.fn(async () => ({ text: '', segments: [], language: 'ko', duration: 0, processingTime: 0 })),
|
||||
}),
|
||||
}))
|
||||
|
||||
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() }),
|
||||
}))
|
||||
|
||||
const history = vi.hoisted(() => ({ create: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/HistoryService', () => ({
|
||||
getHistoryService: () => history,
|
||||
}))
|
||||
|
||||
// 트랙: flush 하면 말하던 마지막 문장을 확정한다
|
||||
vi.mock('../../../src/main/services/caption/StreamingCaptionTrack', () => ({
|
||||
StreamingCaptionTrack: class {
|
||||
constructor(private readonly deps: { onFinal: (text: string) => void }) {}
|
||||
push(): void {}
|
||||
async tick(): Promise<void> {}
|
||||
async flush(): Promise<void> {
|
||||
this.deps.onFinal('정지 직전 마지막 문장')
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
type CaptionModule = typeof import('../../../src/main/services/CaptionService')
|
||||
let mod: CaptionModule
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
config.values = { captionAudioSource: 'system' }
|
||||
history.create.mockReturnValue({ id: 'history-row-1' })
|
||||
mod = await import('../../../src/main/services/CaptionService')
|
||||
mod.resetCaptionServiceForTests()
|
||||
})
|
||||
|
||||
describe('CaptionService 세션 소유권', () => {
|
||||
it('회의가 소유한 세션은 stop(\'user\') 를 거부하고 계속 돈다', async () => {
|
||||
const caption = mod.getCaptionService()
|
||||
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
|
||||
|
||||
expect(await caption.stop('user')).toBe(false)
|
||||
expect(caption.getState()).toBe('active')
|
||||
expect(audio.stop).not.toHaveBeenCalled()
|
||||
|
||||
// 소유자와 강제 정지(인자 없음)는 멈출 수 있다
|
||||
expect(await caption.stop('meeting')).not.toBe(false)
|
||||
expect(caption.getState()).toBe('inactive')
|
||||
})
|
||||
|
||||
it('사용자 세션은 stop(\'user\') 로 멈춘다', async () => {
|
||||
const caption = mod.getCaptionService()
|
||||
await caption.start()
|
||||
expect(caption.getOwner()).toBe('user')
|
||||
await caption.stop('user')
|
||||
expect(caption.getState()).toBe('inactive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CaptionService 정지 요약', () => {
|
||||
it('flush 중 확정된 문장을 요약에 담아 돌려주고, persistHistory:false 면 히스토리를 만들지 않는다', async () => {
|
||||
const caption = mod.getCaptionService()
|
||||
const saved = vi.fn()
|
||||
caption.on('session-saved', saved)
|
||||
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
|
||||
|
||||
const summary = await caption.stop('meeting')
|
||||
|
||||
expect(summary).not.toBe(false)
|
||||
expect(summary && summary.segments.map((s) => s.text)).toEqual(['정지 직전 마지막 문장'])
|
||||
expect(summary && summary.historyId).toBeNull()
|
||||
expect(history.create).not.toHaveBeenCalled()
|
||||
expect(saved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('저장한 세션은 요약에 히스토리 행 id 를 싣는다', async () => {
|
||||
const caption = mod.getCaptionService()
|
||||
const saved = vi.fn()
|
||||
caption.on('session-saved', saved)
|
||||
await caption.start({ audioSource: 'mic' })
|
||||
|
||||
const summary = await caption.stop()
|
||||
|
||||
expect(history.create).toHaveBeenCalledTimes(1)
|
||||
expect(summary && summary.historyId).toBe('history-row-1')
|
||||
expect(saved).toHaveBeenCalledWith(expect.objectContaining({ historyId: 'history-row-1' }))
|
||||
})
|
||||
|
||||
it('audioSource 옵션은 다음 사용자 세션의 설정으로 새지 않는다', async () => {
|
||||
const caption = mod.getCaptionService()
|
||||
caption.setConfig({ audioSource: 'system' })
|
||||
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
|
||||
await caption.stop()
|
||||
expect(config.values.captionAudioSource).toBe('system')
|
||||
expect(caption.getConfig().audioSource).toBe('system')
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// tests/main/services/rag-docx-redteam-r1-3.test.ts
|
||||
// Word 가 만든(deflate 압축) .docx 에서 본문을 뽑는지, ZIP 이 아니면 바이너리 잡음을
|
||||
// 색인하지 않고 실패하는지 확인한다.
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { Document, Packer, Paragraph, TextRun } from 'docx'
|
||||
import {
|
||||
extractDocxText,
|
||||
decodeXmlEntities,
|
||||
wordXmlToText,
|
||||
DocxExtractionError,
|
||||
} from '../../../src/main/services/rag/docx-text'
|
||||
|
||||
async function buildDocx(paragraphs: string[]): Promise<Buffer> {
|
||||
const doc = new Document({
|
||||
sections: [{ children: paragraphs.map((p) => new Paragraph({ children: [new TextRun(p)] })) }],
|
||||
})
|
||||
return Buffer.from(await Packer.toBuffer(doc))
|
||||
}
|
||||
|
||||
describe('extractDocxText', () => {
|
||||
it('압축된 word/document.xml 에서 문단 텍스트를 뽑는다', async () => {
|
||||
const buffer = await buildDocx(['분기 매출 보고서', 'List<String> 타입을 쓰고 A & B 를 비교한다'])
|
||||
// 원본 ZIP 바이트에는 본문이 평문으로 보이지 않는다 (예전 정규식이 실패한 이유)
|
||||
expect(buffer.toString('utf-8')).not.toContain('분기 매출 보고서')
|
||||
|
||||
const text = extractDocxText(buffer)
|
||||
expect(text).toBe('분기 매출 보고서\nList<String> 타입을 쓰고 A & B 를 비교한다')
|
||||
})
|
||||
|
||||
it('ZIP 이 아니면 잡음을 돌려주지 않고 실패한다', () => {
|
||||
const garbage = Buffer.from('PK\u0003\u0004 this is not really a zip archive at all, just junk bytes', 'utf-8')
|
||||
expect(() => extractDocxText(garbage)).toThrow(DocxExtractionError)
|
||||
})
|
||||
|
||||
it('XML 엔티티와 탭/줄바꿈을 복원한다', () => {
|
||||
expect(decodeXmlEntities('a & b <c> AB')).toBe('a & b <c> AB')
|
||||
const xml =
|
||||
'<w:body><w:p><w:r><w:t>하나</w:t><w:tab/><w:t xml:space="preserve"> 둘</w:t></w:r></w:p>' +
|
||||
'<w:p><w:pPr><w:pStyle w:val="x"/></w:pPr><w:r><w:t>셋</w:t><w:br/><w:t>넷</w:t></w:r></w:p><w:p/></w:body>'
|
||||
expect(wordXmlToText(xml)).toBe('하나\t 둘\n셋\n넷')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// tests/main/services/screen-context-redteam-r1-3.test.ts
|
||||
// 선택 텍스트 캡처 회귀 테스트:
|
||||
// - 핫키 수정자(Alt 등)를 쥔 상태면 Ctrl+C 를 보내지 않고 클립보드도 건드리지 않는다
|
||||
// - 캡처 후 클립보드는 저장한 모든 형식(text/html/rtf/image)을 한 번에 복원한다
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { clipboard } from 'electron'
|
||||
|
||||
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(() => true),
|
||||
configSet: vi.fn(),
|
||||
}))
|
||||
|
||||
const modifiers = vi.hoisted(() => ({ released: true }))
|
||||
vi.mock('../../../src/main/services/modifier-state', () => ({
|
||||
waitForModifiersReleased: vi.fn(async () => modifiers.released),
|
||||
}))
|
||||
|
||||
const nut = vi.hoisted(() => ({ pressKey: vi.fn(async () => undefined), releaseKey: vi.fn(async () => undefined) }))
|
||||
vi.mock('@nut-tree-fork/nut-js', () => ({
|
||||
keyboard: nut,
|
||||
Key: { LeftControl: 'LeftControl', C: 'C' },
|
||||
}))
|
||||
|
||||
type Module = typeof import('../../../src/main/services/ScreenContextService')
|
||||
let getScreenContextService: Module['getScreenContextService']
|
||||
|
||||
// 공용 electron 목에는 clipboard.clear 가 없다 — 이 테스트에서만 채운다
|
||||
const clipboardClear = vi.fn()
|
||||
Object.assign(clipboard, { clear: clipboardClear })
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
modifiers.released = true
|
||||
getScreenContextService = (await import('../../../src/main/services/ScreenContextService')).getScreenContextService
|
||||
})
|
||||
|
||||
describe('ScreenContextService.captureSelectedText', () => {
|
||||
it('수정자를 쥐고 있으면 Ctrl+C 를 보내지 않고 클립보드도 비우지 않는다', async () => {
|
||||
modifiers.released = false
|
||||
const text = await getScreenContextService().captureSelectedText()
|
||||
expect(text).toBeNull()
|
||||
expect(nut.pressKey).not.toHaveBeenCalled()
|
||||
expect(clipboardClear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('수정자를 뗀 뒤에는 복사하고, 모든 형식을 한 번에 복원한다', async () => {
|
||||
const image = { isEmpty: () => false, toPNG: () => Buffer.alloc(1) }
|
||||
vi.mocked(clipboard.readText).mockReturnValueOnce('원래 텍스트').mockReturnValueOnce('선택한 문장')
|
||||
vi.mocked(clipboard.readHTML).mockReturnValueOnce('<b>원래</b>')
|
||||
vi.mocked(clipboard.readRTF).mockReturnValueOnce('{\\rtf1 원래}')
|
||||
vi.mocked(clipboard.readImage).mockReturnValueOnce(image as unknown as Electron.NativeImage)
|
||||
|
||||
const text = await getScreenContextService().captureSelectedText()
|
||||
|
||||
expect(text).toBe('선택한 문장')
|
||||
expect(nut.pressKey).toHaveBeenCalledTimes(1)
|
||||
expect(clipboard.write).toHaveBeenCalledWith({
|
||||
text: '원래 텍스트',
|
||||
html: '<b>원래</b>',
|
||||
rtf: '{\\rtf1 원래}',
|
||||
image,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
// tests/main/services/update-service-redteam-r1-3.test.ts
|
||||
// 자동 업데이트 회귀 테스트 (가짜 포트 주입):
|
||||
// - 원격 정책을 체크마다 다시 받는다 (킬 스위치·rollout 이 실행 중 클라이언트에 반영)
|
||||
// - '나중에' 이후에도 새 버전·필수 격상·간격 경과 시 다시 묻는다
|
||||
// - evaluateUpdateOffer 게이팅 순서
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
DEFAULT_UPDATE_POLICY,
|
||||
evaluateUpdateOffer,
|
||||
shouldPromptForUpdate,
|
||||
UPDATE_REPROMPT_INTERVAL_MS,
|
||||
type UpdatePolicy,
|
||||
} from '../../../src/main/update-policy'
|
||||
import type {
|
||||
ConsentAnswer,
|
||||
PolicySource,
|
||||
UpdatePrompter,
|
||||
UpdaterEventHandlers,
|
||||
UpdaterPort,
|
||||
} from '../../../src/main/services/UpdateService'
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
const store = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn((key: string) => store.values[key]),
|
||||
configSet: vi.fn((key: string, value: unknown) => {
|
||||
store.values[key] = value
|
||||
}),
|
||||
configGetAll: vi.fn(() => store.values),
|
||||
}))
|
||||
|
||||
type ServiceModule = typeof import('../../../src/main/services/UpdateService')
|
||||
let UpdateService: ServiceModule['UpdateService']
|
||||
|
||||
function policy(overrides: Partial<UpdatePolicy> = {}): UpdatePolicy {
|
||||
return { ...DEFAULT_UPDATE_POLICY, channels: { ...DEFAULT_UPDATE_POLICY.channels }, ...overrides }
|
||||
}
|
||||
|
||||
function harness(opts: { answers?: ConsentAnswer[]; policies?: UpdatePolicy[] } = {}) {
|
||||
const answers = [...(opts.answers ?? [])]
|
||||
const policies = [...(opts.policies ?? [policy()])]
|
||||
let clock = 1_000_000
|
||||
let handlers: UpdaterEventHandlers | null = null
|
||||
const updater: UpdaterPort & { checks: number; downloads: number } = {
|
||||
checks: 0,
|
||||
downloads: 0,
|
||||
checkForUpdates: vi.fn(async () => {
|
||||
updater.checks++
|
||||
}),
|
||||
downloadUpdate: vi.fn(async () => {
|
||||
updater.downloads++
|
||||
}),
|
||||
quitAndInstall: vi.fn(),
|
||||
setChannel: vi.fn(),
|
||||
setDifferentialDisabled: vi.fn(),
|
||||
}
|
||||
const policySource: PolicySource & { loads: number } = {
|
||||
loads: 0,
|
||||
load: vi.fn(async () => {
|
||||
policySource.loads++
|
||||
return policies.length > 1 ? (policies.shift() as UpdatePolicy) : policies[0]
|
||||
}),
|
||||
}
|
||||
const consents: string[] = []
|
||||
const prompter: UpdatePrompter = {
|
||||
askConsent: vi.fn(async ({ version }) => {
|
||||
consents.push(version)
|
||||
return answers.shift() ?? 'later'
|
||||
}),
|
||||
askRestart: vi.fn(async () => 'later' as const),
|
||||
}
|
||||
const service = new UpdateService({
|
||||
createUpdater: (h) => {
|
||||
handlers = h
|
||||
return updater
|
||||
},
|
||||
policySource,
|
||||
prompter,
|
||||
currentVersion: () => '1.9.0',
|
||||
now: () => clock,
|
||||
})
|
||||
service.initialize()
|
||||
service.dispose() // 타이머는 쓰지 않는다 — 테스트가 직접 호출
|
||||
return {
|
||||
service,
|
||||
updater,
|
||||
policySource,
|
||||
consents,
|
||||
handlers: () => handlers as UpdaterEventHandlers,
|
||||
advance: (ms: number) => {
|
||||
clock += ms
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
store.values = { updateDeviceId: 'device-1' }
|
||||
UpdateService = (await import('../../../src/main/services/UpdateService')).UpdateService
|
||||
})
|
||||
|
||||
describe('UpdateService — 원격 정책 갱신', () => {
|
||||
it('체크할 때마다 정책을 다시 받고, 킬 스위치가 켜지면 확인을 멈춘다', async () => {
|
||||
const h = harness({ policies: [policy(), policy({ killSwitch: true })] })
|
||||
|
||||
await h.service.checkForUpdates()
|
||||
expect(h.updater.checks).toBe(1)
|
||||
|
||||
await h.service.checkForUpdates()
|
||||
expect(h.policySource.loads).toBe(2)
|
||||
expect(h.updater.checks).toBe(1)
|
||||
})
|
||||
|
||||
it('킬 스위치가 켜진 뒤 도착한 update-available 도 무시한다', async () => {
|
||||
const h = harness({ policies: [policy({ killSwitch: true })] })
|
||||
await h.service.checkForUpdates()
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||
expect(h.consents).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('UpdateService — 다시 묻기', () => {
|
||||
it("'나중에' 이후 새 버전이 나오면 다시 묻는다", async () => {
|
||||
const h = harness({ answers: ['later', 'later'] })
|
||||
await h.service.checkForUpdates()
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.2' })
|
||||
expect(h.consents).toEqual(['1.9.1', '1.9.2'])
|
||||
})
|
||||
|
||||
it('같은 버전은 간격 안에서는 다시 묻지 않고, 간격이 지나면 다시 묻는다', async () => {
|
||||
const h = harness({ answers: ['later', 'later'] })
|
||||
await h.service.checkForUpdates()
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||
expect(h.consents).toEqual(['1.9.1'])
|
||||
|
||||
h.advance(UPDATE_REPROMPT_INTERVAL_MS)
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
|
||||
})
|
||||
|
||||
it('필수로 격상되면 간격과 무관하게 다시 묻는다', async () => {
|
||||
const h = harness({
|
||||
answers: ['later', 'download'],
|
||||
policies: [policy(), policy({ minimumSupportedVersion: '1.9.1' })],
|
||||
})
|
||||
await h.service.checkForUpdates()
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||
await h.service.checkForUpdates()
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
|
||||
expect(h.updater.downloads).toBe(1)
|
||||
})
|
||||
|
||||
it('수락한 다운로드가 실패하면 다음 체크에서 다시 묻는다', async () => {
|
||||
const h = harness({ answers: ['download', 'later'] })
|
||||
await h.service.checkForUpdates()
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||
expect(h.updater.downloads).toBe(1)
|
||||
|
||||
h.handlers().onError('network down')
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
|
||||
})
|
||||
|
||||
it("'이 버전 건너뛰기' 는 그 버전을 다시 묻지 않는다", async () => {
|
||||
const h = harness({ answers: ['skip'] })
|
||||
await h.service.checkForUpdates()
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||
h.advance(UPDATE_REPROMPT_INTERVAL_MS * 2)
|
||||
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||
expect(h.consents).toEqual(['1.9.1'])
|
||||
expect(store.values.skippedUpdateVersion).toBe('1.9.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluateUpdateOffer 게이팅 순서', () => {
|
||||
const base = {
|
||||
channel: 'latest' as const,
|
||||
currentVersion: '1.9.0',
|
||||
skippedVersion: null,
|
||||
deviceId: 'device-1',
|
||||
}
|
||||
|
||||
it('킬 스위치가 가장 먼저다', () => {
|
||||
const offer = evaluateUpdateOffer({ ...base, policy: policy({ killSwitch: true }), targetVersion: '1.9.1' })
|
||||
expect(offer).toMatchObject({ action: 'ignore', reason: 'kill-switch' })
|
||||
})
|
||||
|
||||
it('stable 채널은 prerelease 를 거부한다', () => {
|
||||
const offer = evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.10.0-beta.1' })
|
||||
expect(offer).toMatchObject({ action: 'ignore', reason: 'channel' })
|
||||
})
|
||||
|
||||
it('필수 업데이트는 건너뛰기·rollout 을 무시한다', () => {
|
||||
const offer = evaluateUpdateOffer({
|
||||
...base,
|
||||
policy: policy({ minimumSupportedVersion: '1.9.1', stagingPercentage: 0 }),
|
||||
targetVersion: '1.9.1',
|
||||
skippedVersion: '1.9.1',
|
||||
})
|
||||
expect(offer).toMatchObject({ action: 'prompt', decision: { mandatory: true } })
|
||||
})
|
||||
|
||||
it('건너뛴 버전 / rollout 밖은 무시한다', () => {
|
||||
expect(
|
||||
evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.9.1', skippedVersion: '1.9.1' }),
|
||||
).toMatchObject({ action: 'ignore', reason: 'skipped' })
|
||||
expect(
|
||||
evaluateUpdateOffer({ ...base, policy: policy({ stagingPercentage: 0 }), targetVersion: '1.9.1' }),
|
||||
).toMatchObject({ action: 'ignore', reason: 'rollout' })
|
||||
})
|
||||
|
||||
it('강제 설치 하한 미만이면 대화상자 없이 다운로드한다', () => {
|
||||
const offer = evaluateUpdateOffer({ ...base, policy: policy({ forceInstallBelow: '1.9.1' }), targetVersion: '2.0.0' })
|
||||
expect(offer).toMatchObject({ action: 'force-download', isMajorUpgrade: true })
|
||||
})
|
||||
|
||||
it('shouldPromptForUpdate: 기록 없음 / 새 버전 / 필수 격상 / 간격 경과', () => {
|
||||
const last = { version: '1.9.1', mandatory: false, at: 0 }
|
||||
expect(shouldPromptForUpdate(null, { version: '1.9.1', mandatory: false }, 0)).toBe(true)
|
||||
expect(shouldPromptForUpdate(last, { version: '1.9.2', mandatory: false }, 1)).toBe(true)
|
||||
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: true }, 1)).toBe(true)
|
||||
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, 1)).toBe(false)
|
||||
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, UPDATE_REPROMPT_INTERVAL_MS)).toBe(true)
|
||||
})
|
||||
})
|
||||
389
apps/desktop/tests/main/services/voice-mode-redteam-r1-3.test.ts
Normal file
389
apps/desktop/tests/main/services/voice-mode-redteam-r1-3.test.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
// tests/main/services/voice-mode-redteam-r1-3.test.ts
|
||||
// VoiceModeService 세션 런(SessionRun) 회귀 테스트:
|
||||
// - 취소된 세션의 늦은 STT 결과가 다음 세션에 삽입되지 않는다
|
||||
// - 지연 전사 중 두 번째 release 가 받아쓰기를 취소하지 않는다
|
||||
// - 오디오 캡처 참조는 세션당 정확히 한 번 놓는다 (start 대기 중 취소 포함)
|
||||
// - 녹음 중 캡처가 죽으면(장치 분리) 에러로 알린다
|
||||
// - 회의 모드가 소유한 자막은 자막 핫키로 끌 수 없다
|
||||
// - 표시 계층 이벤트(phase / insert-failed)
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { EventEmitter } from 'events'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService'
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (err: unknown) => void
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (err: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const result = (text: string): { text: string; segments: never[]; language: string; duration: number; processingTime: number } => ({
|
||||
text,
|
||||
segments: [],
|
||||
language: 'ko',
|
||||
duration: 1,
|
||||
processingTime: 1,
|
||||
})
|
||||
|
||||
const mockSTT = vi.hoisted(() => ({
|
||||
initialize: vi.fn(),
|
||||
transcribe: vi.fn(),
|
||||
transcribePartial: vi.fn(async () => ''),
|
||||
getModels: vi.fn(() => [
|
||||
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 },
|
||||
]),
|
||||
getStatus: vi.fn(() => ({ engineState: 'ready', activeModel: 'base', engineVersion: null, gpuAccelerated: false })),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||
getLocalSTTService: () => mockSTT,
|
||||
resetLocalSTTServiceForTests: () => undefined,
|
||||
}))
|
||||
|
||||
const audioBus = new EventEmitter()
|
||||
const mockAudio = vi.hoisted(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||
getAudioCaptureService: () => mockAudio,
|
||||
}))
|
||||
|
||||
const keyBinding = vi.hoisted(() => ({
|
||||
handler: null as ((payload: KeyBindingTriggerPayload) => void) | null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/KeyBindingService', () => ({
|
||||
getKeyBindingService: () => ({
|
||||
on: (_ev: string, fn: (payload: KeyBindingTriggerPayload) => void) => {
|
||||
keyBinding.handler = fn
|
||||
},
|
||||
off: 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]),
|
||||
}))
|
||||
|
||||
const mockInsert = vi.hoisted(() => ({ insertText: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
||||
getTextInsertService: () => mockInsert,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({
|
||||
isAvailable: () => true,
|
||||
processText: vi.fn(async (text: string) => text),
|
||||
generate: vi.fn(),
|
||||
chatStream: vi.fn(),
|
||||
cancelGeneration: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const caption = vi.hoisted(() => ({
|
||||
state: 'inactive' as string,
|
||||
stop: vi.fn(),
|
||||
start: vi.fn(async () => undefined),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/CaptionService', () => ({
|
||||
getCaptionService: () => ({
|
||||
getState: () => caption.state,
|
||||
stop: caption.stop,
|
||||
start: caption.start,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/MeetingModeService', () => ({
|
||||
getMeetingModeService: () => ({ getState: () => 'recording' }),
|
||||
}))
|
||||
|
||||
type VoiceModeModule = typeof import('../../../src/main/services/VoiceModeService')
|
||||
let getVoiceModeService: VoiceModeModule['getVoiceModeService']
|
||||
|
||||
const SPEECH = Buffer.alloc(16000 * 2) // 1초
|
||||
|
||||
async function flush(times = 5): Promise<void> {
|
||||
for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r))
|
||||
}
|
||||
|
||||
/** Date.now 를 앞으로 당겨 accidental-press(700ms) 판정을 건너뛴다 */
|
||||
function advanceClock(ms: number): void {
|
||||
const base = Date.now()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(base + ms)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
audioBus.removeAllListeners()
|
||||
config.values = { sttModelId: 'base', defaultLLMAction: 'none', autoInsert: true, llmBackend: 'local' }
|
||||
caption.state = 'inactive'
|
||||
keyBinding.handler = null
|
||||
// once 큐가 테스트 사이로 새지 않게 완전히 초기화한다
|
||||
for (const fn of [mockSTT.initialize, mockSTT.transcribe, mockSTT.transcribePartial, mockSTT.getModels, mockAudio.start, mockAudio.stop, mockInsert.insertText, caption.stop]) {
|
||||
fn.mockReset()
|
||||
}
|
||||
mockSTT.initialize.mockResolvedValue(undefined)
|
||||
mockSTT.transcribe.mockResolvedValue(result('기본 전사'))
|
||||
mockSTT.transcribePartial.mockResolvedValue('')
|
||||
mockSTT.getModels.mockReturnValue([
|
||||
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 },
|
||||
])
|
||||
mockAudio.start.mockResolvedValue(undefined)
|
||||
mockAudio.stop.mockResolvedValue(undefined)
|
||||
mockAudio.on.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
|
||||
audioBus.on(ev, fn)
|
||||
})
|
||||
mockAudio.off.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
|
||||
audioBus.off(ev, fn)
|
||||
})
|
||||
mockInsert.insertText.mockResolvedValue({ success: true, method: 'clipboard', textLength: 1, durationMs: 1 })
|
||||
const mod = await import('../../../src/main/services/VoiceModeService')
|
||||
mod.resetVoiceModeServiceForTests()
|
||||
getVoiceModeService = mod.getVoiceModeService
|
||||
})
|
||||
|
||||
describe('세션 런 — 늦게 도착한 결과', () => {
|
||||
it('취소된 세션 A 의 STT 결과는 새 세션 B 에 삽입되지 않는다', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
const sttA = deferred<ReturnType<typeof result>>()
|
||||
mockSTT.transcribe.mockReturnValueOnce(sttA.promise)
|
||||
const completed: string[] = []
|
||||
svc.on('session-completed', ({ finalText }) => completed.push(finalText))
|
||||
|
||||
// 세션 A: 녹음 → 정지 → 전사 진행 중
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
const stopA = svc.stopSession()
|
||||
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
|
||||
|
||||
// A 취소 → B 시작
|
||||
svc.cancelSession()
|
||||
await svc.startSession('dictation')
|
||||
const sessionB = svc.currentSession
|
||||
expect(sessionB).not.toBeNull()
|
||||
|
||||
// A 의 결과가 늦게 도착
|
||||
sttA.resolve(result('세션 A 텍스트'))
|
||||
await stopA
|
||||
await flush()
|
||||
|
||||
expect(completed).toEqual([])
|
||||
expect(mockInsert.insertText).not.toHaveBeenCalled()
|
||||
expect(svc.currentSession?.id).toBe(sessionB?.id)
|
||||
expect(svc.isActive).toBe(true)
|
||||
})
|
||||
|
||||
it('취소된 세션의 늦은 STT 실패는 새 세션을 에러로 끝내지 않는다', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
const sttA = deferred<ReturnType<typeof result>>()
|
||||
mockSTT.transcribe.mockReturnValueOnce(sttA.promise)
|
||||
const errors: number[] = []
|
||||
svc.on('error', ({ error }) => errors.push(error.code))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
const stopA = svc.stopSession()
|
||||
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
|
||||
svc.cancelSession()
|
||||
await svc.startSession('dictation')
|
||||
|
||||
sttA.reject(new Error('sidecar exploded'))
|
||||
await stopA
|
||||
await flush()
|
||||
|
||||
expect(errors).toEqual([])
|
||||
expect(svc.isActive).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('세션 런 — 정지 멱등성', () => {
|
||||
it('지연 전사 중 두 번째 정지는 받아쓰기를 취소하지 않는다', async () => {
|
||||
const init = deferred<void>()
|
||||
mockSTT.initialize.mockReturnValueOnce(init.promise)
|
||||
const stt = deferred<ReturnType<typeof result>>()
|
||||
mockSTT.transcribe.mockReturnValueOnce(stt.promise)
|
||||
|
||||
const svc = getVoiceModeService()
|
||||
const cancelled: string[] = []
|
||||
const completed: string[] = []
|
||||
svc.on('session-cancelled', ({ reason }) => cancelled.push(reason))
|
||||
svc.on('session-completed', ({ finalText }) => completed.push(finalText))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
await svc.stopSession() // STT 미준비 → 대기
|
||||
|
||||
init.resolve() // 모델 로딩 완료 → 지연 전사 시작 (버퍼 비움)
|
||||
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
|
||||
|
||||
await svc.stopSession() // 두 번째 release
|
||||
expect(cancelled).toEqual([])
|
||||
|
||||
stt.resolve(result('살아남은 받아쓰기'))
|
||||
await vi.waitFor(() => expect(completed).toEqual(['살아남은 받아쓰기']))
|
||||
expect(mockInsert.insertText).toHaveBeenCalledWith('살아남은 받아쓰기', undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('세션 런 — 오디오 참조 소유', () => {
|
||||
it('정지 후 "No speech" 에러가 나도 audio.stop 은 세션당 한 번이다', async () => {
|
||||
mockSTT.transcribe.mockResolvedValueOnce(result(' '))
|
||||
const svc = getVoiceModeService()
|
||||
const errors: number[] = []
|
||||
svc.on('error', ({ error }) => errors.push(error.code))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
await svc.stopSession()
|
||||
await vi.waitFor(() => expect(errors).toEqual([ErrorCode.STTNoAudioData]))
|
||||
expect(mockAudio.start).toHaveBeenCalledTimes(1)
|
||||
expect(mockAudio.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('audio.start 대기 중 취소돼도 늦게 잡힌 참조를 정확히 한 번 놓는다', async () => {
|
||||
const start = deferred<void>()
|
||||
mockAudio.start.mockReturnValueOnce(start.promise)
|
||||
const svc = getVoiceModeService()
|
||||
|
||||
const starting = svc.startSession('dictation')
|
||||
await vi.waitFor(() => expect(mockAudio.start).toHaveBeenCalledTimes(1))
|
||||
svc.cancelSession()
|
||||
expect(mockAudio.stop).not.toHaveBeenCalled()
|
||||
|
||||
start.resolve()
|
||||
await starting
|
||||
await flush()
|
||||
expect(mockAudio.stop).toHaveBeenCalledTimes(1)
|
||||
expect(audioBus.listenerCount('audio-data')).toBe(0)
|
||||
})
|
||||
|
||||
it('세션이 끝나면 오디오 핸들러가 모두 해제된다 (다음 세션 프레임 중복 없음)', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
await svc.stopSession()
|
||||
await vi.waitFor(() => expect(svc.isActive).toBe(false))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
expect(audioBus.listenerCount('audio-data')).toBe(1)
|
||||
expect(audioBus.listenerCount('stopped')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('캡처 손실', () => {
|
||||
it('오디오 없이 캡처가 죽으면 AudioCaptureFailed 에러를 낸다', async () => {
|
||||
const svc = getVoiceModeService()
|
||||
const errors: number[] = []
|
||||
svc.on('error', ({ error }) => errors.push(error.code))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('stopped', { reason: 'device-lost' })
|
||||
await flush()
|
||||
|
||||
expect(errors).toEqual([ErrorCode.AudioCaptureFailed])
|
||||
expect(svc.isActive).toBe(false)
|
||||
// 캡처 쪽이 이미 정리했으므로 다른 소비자의 참조를 깎지 않는다
|
||||
expect(mockAudio.stop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("수동 정지('manual')는 캡처 손실로 보지 않는다", async () => {
|
||||
const svc = getVoiceModeService()
|
||||
const errors: number[] = []
|
||||
svc.on('error', ({ error }) => errors.push(error.code))
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('stopped', { reason: 'manual' })
|
||||
await flush()
|
||||
expect(errors).toEqual([])
|
||||
expect(svc.isActive).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('표시 이벤트', () => {
|
||||
it("모델 확인을 통과한 뒤에만 'recording' phase 를 내보낸다", async () => {
|
||||
mockSTT.getModels.mockReturnValueOnce([
|
||||
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: false, languages: [], accuracy: 2, speed: 4 },
|
||||
])
|
||||
const svc = getVoiceModeService()
|
||||
const phases: string[] = []
|
||||
const errors: number[] = []
|
||||
svc.on('phase', ({ phase }) => phases.push(phase))
|
||||
svc.on('error', ({ error }) => errors.push(error.code))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
|
||||
expect(phases).toEqual([])
|
||||
expect(errors).toEqual([ErrorCode.STTModelNotFound])
|
||||
})
|
||||
|
||||
it("녹음 → 정지에서 'recording' 다음 'thinking' phase 가 나간다", async () => {
|
||||
const svc = getVoiceModeService()
|
||||
const phases: string[] = []
|
||||
svc.on('phase', ({ phase }) => phases.push(phase))
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
await svc.stopSession()
|
||||
expect(phases).toEqual(['recording', 'thinking'])
|
||||
})
|
||||
|
||||
it('자동 삽입이 실패하면 insert-failed 를 내보낸다', async () => {
|
||||
mockInsert.insertText.mockRejectedValueOnce(new Error('clipboard busy'))
|
||||
const svc = getVoiceModeService()
|
||||
const failed: string[] = []
|
||||
svc.on('insert-failed', ({ text }) => failed.push(text))
|
||||
|
||||
await svc.startSession('dictation')
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(1000)
|
||||
await svc.stopSession()
|
||||
await vi.waitFor(() => expect(failed).toEqual(['기본 전사']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('자막 핫키', () => {
|
||||
it('회의 모드가 소유한 자막 세션은 자막 핫키로 멈추지 않는다', async () => {
|
||||
caption.state = 'active'
|
||||
caption.stop.mockResolvedValue(false)
|
||||
const svc = getVoiceModeService()
|
||||
svc.connectKeyBindings()
|
||||
expect(keyBinding.handler).not.toBeNull()
|
||||
|
||||
keyBinding.handler?.({
|
||||
actionId: 'caption',
|
||||
type: 'pressed',
|
||||
timestamp: Date.now(),
|
||||
isDoublePress: false,
|
||||
holdMode: false,
|
||||
} as KeyBindingTriggerPayload)
|
||||
// 소유자('user')를 밝혀 요청하므로 CaptionService 가 거부할 수 있다
|
||||
await vi.waitFor(() => expect(caption.stop).toHaveBeenCalledWith('user'))
|
||||
expect(caption.start).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue