refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes

This commit is contained in:
Yun Chan 2026-09-28 00:53:43 +09:00
parent 0adedf5e7b
commit 83cf9133ac
39 changed files with 4787 additions and 1061 deletions

View file

@ -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)
})
})

View file

@ -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')
})
})

View 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()
})
})

View file

@ -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 &amp; b &lt;c&gt; &#65;&#x42;')).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넷')
})
})

View file

@ -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,
})
})
})

View file

@ -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)
})
})

View file

@ -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)
})
})

View 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()
})
})