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

View file

@ -0,0 +1,72 @@
// tests/main/shutdown-registry.test.ts
// 종료 등록부 — 역순 정리, 멱등, 멈춘 항목이 종료를 막지 않음.
import { describe, it, expect, vi } from 'vitest'
import { ShutdownRegistry } from '../../src/main/shutdown-registry'
describe('ShutdownRegistry', () => {
it('등록의 역순으로 정리한다', async () => {
const order: string[] = []
const registry = new ShutdownRegistry()
registry.register({ name: 'a', dispose: () => { order.push('a') } })
registry.register({ name: 'b', dispose: async () => { order.push('b') } })
registry.register({ name: 'c', dispose: () => { order.push('c') } })
const report = await registry.disposeAll(1000)
expect(order).toEqual(['c', 'b', 'a'])
expect(report.completed).toEqual(['c', 'b', 'a'])
})
it('여러 번 불러도 한 번만 정리한다', async () => {
const dispose = vi.fn()
const registry = new ShutdownRegistry()
registry.register({ name: 'x', dispose })
await Promise.all([registry.disposeAll(1000), registry.disposeAll(1000)])
await registry.disposeAll(1000)
expect(dispose).toHaveBeenCalledTimes(1)
})
it('실패한 항목은 기록하고 나머지를 계속 정리한다', async () => {
const onError = vi.fn()
const later = vi.fn()
const registry = new ShutdownRegistry({ onError })
registry.register({ name: 'later', dispose: later })
registry.register({ name: 'throws', dispose: () => { throw new Error('sync boom') } })
registry.register({ name: 'rejects', dispose: async () => { throw new Error('async boom') } })
const report = await registry.disposeAll(1000)
expect(report.failed).toEqual(['rejects', 'throws'])
expect(report.completed).toEqual(['later'])
expect(later).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledTimes(2)
})
it('멈춘 항목이 있어도 상한 시간 안에 끝나고, 남은 항목의 dispose 는 걸어 둔다', async () => {
vi.useFakeTimers()
try {
const last = vi.fn()
const registry = new ShutdownRegistry()
registry.register({ name: 'last', dispose: last })
registry.register({ name: 'hangs', dispose: () => new Promise<void>(() => undefined) })
const running = registry.disposeAll(500)
await vi.advanceTimersByTimeAsync(501)
const report = await running
expect(report.timedOut).toEqual(['hangs', 'last'])
// 기다리진 않지만 동기 구간(프로세스 kill 등)은 실행된다
expect(last).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('등록 취소한 항목은 정리하지 않는다', async () => {
const dispose = vi.fn()
const registry = new ShutdownRegistry()
const unregister = registry.register({ name: 'x', dispose })
unregister()
await registry.disposeAll(1000)
expect(dispose).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,162 @@
// tests/main/windows/popup-navigation.test.ts
// 히스토리 팝업을 연 채 명령어 팝업을 열면 Enter 가 히스토리 항목을 삽입하던 버그의 회귀 테스트.
import { describe, it, expect, vi } from 'vitest'
import {
PopupNavigationController,
POPUP_NAV_KEYS,
HISTORY_INSERT_DELAY_MS,
type PopupKind,
type PopupWindowPort,
} from '../../../src/main/windows/popup-navigation'
interface FakeWindow extends PopupWindowPort {
visible: boolean
keys: string[]
}
function fakeWindow(): FakeWindow {
const win: FakeWindow = {
visible: false,
keys: [],
show: () => {
win.visible = true
},
hide: () => {
win.visible = false
},
isVisible: () => win.visible,
sendKey: (key) => {
win.keys.push(key)
},
}
return win
}
/** Electron globalShortcut 처럼 이미 등록된 가속기의 재등록은 false 로 거부한다 */
function fakeShortcuts(): {
registered: Map<string, () => void>
register: (accel: string, cb: () => void) => boolean
unregister: (accel: string) => void
press: (accel: string) => void
} {
const registered = new Map<string, () => void>()
return {
registered,
register: (accel, cb) => {
if (registered.has(accel)) return false
registered.set(accel, cb)
return true
},
unregister: (accel) => {
registered.delete(accel)
},
press: (accel) => registered.get(accel)?.(),
}
}
function setup(): {
controller: PopupNavigationController
windows: Record<PopupKind, FakeWindow>
shortcuts: ReturnType<typeof fakeShortcuts>
inserted: string[]
scheduled: Array<{ fn: () => void; ms: number }>
selections: Array<{ id: string; name: string }>
} {
const windows = { history: fakeWindow(), command: fakeWindow() }
const shortcuts = fakeShortcuts()
const inserted: string[] = []
const scheduled: Array<{ fn: () => void; ms: number }> = []
const selections: Array<{ id: string; name: string }> = []
const controller = new PopupNavigationController({
windows,
shortcuts,
insertText: async (text) => {
inserted.push(text)
},
applyCommandSelection: (sel) => selections.push(sel),
schedule: (fn, ms) => scheduled.push({ fn, ms }),
})
return { controller, windows, shortcuts, inserted, scheduled, selections }
}
describe('PopupNavigationController', () => {
it('히스토리 → 명령어 전환 시 히스토리를 닫고 Enter 는 명령어 팝업으로 간다', () => {
const { controller, windows, shortcuts } = setup()
controller.toggle('history')
controller.toggle('command')
expect(windows.history.visible).toBe(false)
expect(windows.command.visible).toBe(true)
expect(controller.owner).toBe('command')
shortcuts.press('Return')
expect(windows.command.keys).toEqual(['Enter'])
expect(windows.history.keys).toEqual([])
})
it('소유자가 아닌 팝업을 닫아도 소유자의 키를 뺏지 않는다', () => {
const { controller, windows, shortcuts } = setup()
controller.show('command')
controller.dismiss('history')
expect(shortcuts.registered.size).toBe(POPUP_NAV_KEYS.length)
shortcuts.press('Down')
expect(windows.command.keys).toEqual(['ArrowDown'])
})
it('소유 팝업을 닫으면 전역 내비게이션 키를 모두 해제한다', () => {
const { controller, shortcuts } = setup()
controller.toggle('history')
expect(shortcuts.registered.size).toBe(POPUP_NAV_KEYS.length)
controller.toggle('history')
expect(shortcuts.registered.size).toBe(0)
expect(controller.owner).toBeNull()
})
it('히스토리 선택은 팝업을 닫고 지연 뒤 삽입한다', async () => {
const { controller, windows, inserted, scheduled, shortcuts } = setup()
controller.show('history')
controller.selectHistoryItem('붙여넣을 텍스트')
expect(windows.history.visible).toBe(false)
expect(shortcuts.registered.size).toBe(0)
expect(scheduled).toHaveLength(1)
expect(scheduled[0].ms).toBe(HISTORY_INSERT_DELAY_MS)
expect(inserted).toEqual([])
scheduled[0].fn()
await Promise.resolve()
expect(inserted).toEqual(['붙여넣을 텍스트'])
})
it('명령어 선택은 팝업을 닫고 선택을 반영한다', () => {
const { controller, windows, selections } = setup()
controller.show('command')
controller.selectCommand({ id: 'cmd-1', name: '번역' })
expect(windows.command.visible).toBe(false)
expect(selections).toEqual([{ id: 'cmd-1', name: '번역' }])
})
it('팝업이 다른 경로로 사라졌으면 다음 키에서 전역 키를 놓는다', () => {
const { controller, windows, shortcuts } = setup()
controller.show('history')
windows.history.visible = false
shortcuts.press('Return')
expect(shortcuts.registered.size).toBe(0)
expect(windows.history.keys).toEqual([])
})
it('등록 실패(다른 앱이 점유)를 경고한다', () => {
const warn = vi.fn()
const windows = { history: fakeWindow(), command: fakeWindow() }
const controller = new PopupNavigationController({
windows,
shortcuts: { register: () => false, unregister: () => undefined },
insertText: async () => undefined,
applyCommandSelection: () => undefined,
warn,
})
controller.show('history')
expect(warn).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,94 @@
// tests/main/windows/recording-tip-presenter.test.ts
// RecordingTip 표시 정책 — 에러 숨김 타이머가 다음 세션의 녹음 팁을 숨기던 버그의 회귀 테스트.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
RecordingTipPresenter,
type RecordingTipView,
} from '../../../src/main/windows/recording-tip-presenter'
function fakeView(): RecordingTipView & { calls: string[] } {
const calls: string[] = []
return {
calls,
showRecording: () => calls.push('show'),
hide: () => calls.push('hide'),
showThinking: () => calls.push('thinking'),
showError: (message) => calls.push(`error:${message}`),
sendAudioLevel: (level) => calls.push(`level:${level}`),
sendPartialTranscript: (text) => calls.push(`partial:${text}`),
showResult: (text, ms) => calls.push(`result:${text}:${ms}`),
}
}
describe('RecordingTipPresenter', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('에러 직후 3초 안에 시작한 새 세션의 팁은 숨겨지지 않는다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('No speech detected')
vi.advanceTimersByTime(1000)
presenter.onSessionStarted()
presenter.onPhase('recording')
vi.advanceTimersByTime(5000)
expect(view.calls).toEqual(['error:No speech detected', 'show'])
})
it('에러는 3초 뒤 한 번 숨긴다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('boom')
vi.advanceTimersByTime(2999)
expect(view.calls).toEqual(['error:boom'])
vi.advanceTimersByTime(1)
expect(view.calls).toEqual(['error:boom', 'hide'])
})
it("'warning' 은 팁을 건드리지 않는다 (Ollama 미가용 알림 등)", () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onPhase('recording')
presenter.onError('Ollama unavailable', 'warning')
vi.advanceTimersByTime(5000)
expect(view.calls).toEqual(['show'])
})
it('연속 에러는 숨김 예약을 하나로 유지한다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('a')
vi.advanceTimersByTime(2000)
presenter.onError('b')
vi.advanceTimersByTime(2000)
expect(view.calls).toEqual(['error:a', 'error:b'])
vi.advanceTimersByTime(1000)
expect(view.calls).toEqual(['error:a', 'error:b', 'hide'])
})
it('완료·취소는 즉시 숨기고, 삽입 실패는 결과 팝업을 띄운다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onPhase('recording')
presenter.onPhase('thinking')
presenter.onSessionEnded()
presenter.onInsertFailed('text')
expect(view.calls).toEqual(['show', 'thinking', 'hide', 'result:text:10000'])
})
it('dispose 는 남은 숨김 예약을 지운다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('x')
presenter.dispose()
vi.advanceTimersByTime(5000)
expect(view.calls).toEqual(['error:x'])
})
})

View file

@ -0,0 +1,226 @@
// tests/red/meeting-mode-redteam-r1-3.test.ts
// MeetingModeService 회귀 테스트 (실제 DB + 가짜 CaptionService / LLM):
// - 정지 직전 문장(자막 flush 로 확정)이 회의 전사에 남는다
// - 전사 줄은 epoch 이 아니라 회의 시작 기준 [MM:SS] 로 저장된다
// - 회의는 자기 소유 자막 세션을 시작한다 (마이크 고정, 오버레이·히스토리 저장 없음, 전역 설정 불변)
// - 회의 AI 채팅 히스토리는 회의마다 분리된다
// - 긴 전사 다듬기가 잘리면 editedTranscript 를 덮어쓰지 않는다
// - 회의 삭제 시 로컬 녹음 WAV 도 지운다
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { EventEmitter } from 'events'
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
import { eq } from 'drizzle-orm'
import { ErrorCode } from '@d3ro/core/errors'
import type { CaptionSegment } from '@d3ro/core/types'
import { useRedHarness } from './harness'
const caption = vi.hoisted(() => ({
bus: null as EventEmitter | null,
state: 'inactive' as string,
startOptions: [] as unknown[],
stopOwners: [] as unknown[],
flushText: '마지막 문장',
}))
vi.mock('../../src/main/services/CaptionService', async () => {
const { EventEmitter: Emitter } = await import('events')
const bus = new Emitter()
caption.bus = bus
const service = {
getState: () => caption.state,
on: (ev: string, fn: (...args: unknown[]) => void) => bus.on(ev, fn),
off: (ev: string, fn: (...args: unknown[]) => void) => bus.off(ev, fn),
start: async (opts: unknown) => {
caption.startOptions.push(opts)
caption.state = 'active'
},
stop: async (owner?: unknown) => {
caption.stopOwners.push(owner)
if (caption.state !== 'active') return null
// CaptionService.stop 은 말하던 문장을 flush 해 'segment' 로 확정한 뒤 끝난다
const segment: CaptionSegment = {
id: 'seg-last',
text: caption.flushText,
timestamp: Date.now() + 10_000,
isFinal: true,
}
bus.emit('segment', segment)
caption.state = 'inactive'
return { sessionId: 'cap', historyId: null, segments: [segment], startedAt: 0, endedAt: 0, totalDurationMs: 0 }
},
}
return {
getCaptionService: () => service,
resetCaptionServiceForTests: () => undefined,
}
})
vi.mock('../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({ on: vi.fn(), off: vi.fn(), start: vi.fn(), stop: vi.fn(async () => undefined) }),
calculateRMS: () => 0,
}))
const llm = vi.hoisted(() => ({
chatCalls: [] as Array<Array<{ role: string; content: string }>>,
generate: null as null | ((text: string, options?: { maxTokens?: number }) => Promise<{ text: string }>),
}))
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
return {
...actual,
getLocalLLMService: () => ({
isAvailable: () => true,
async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator<string, string> {
llm.chatCalls.push(messages)
yield '답변'
return '답변'
},
generate: (text: string, options?: { maxTokens?: number }) =>
llm.generate ? llm.generate(text, options) : Promise.resolve({ text }),
cancelGeneration: vi.fn(),
}),
}
})
useRedHarness()
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
import { configGet, configSet } from '../../src/main/services/ConfigService'
import { getDatabase } from '../../src/main/db'
import { meetingSessions } from '../../src/main/db/schema'
function insertSession(id: string, rawTranscript: string): void {
const now = Date.now()
getDatabase()
.insert(meetingSessions)
.values({
id,
title: id,
status: 'completed',
startedAt: now,
endedAt: now + 1000,
durationMs: 1000,
rawTranscript,
createdAt: now,
updatedAt: now,
})
.run()
}
beforeEach(() => {
caption.state = 'inactive'
caption.startOptions = []
caption.stopOwners = []
llm.chatCalls = []
llm.generate = null
})
describe('회의 녹음 정지', () => {
it('정지 직전 문장을 잃지 않고, 전사는 회의 시작 기준 시각으로 저장된다', async () => {
configSet('captionAudioSource', 'system')
const meeting = getMeetingModeService()
const { sessionId } = await meeting.startRecording()
expect(caption.startOptions).toEqual([
{ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false },
])
// 전역 설정은 건드리지 않는다
expect(configGet('captionAudioSource')).toBe('system')
const startedAt = Date.now()
caption.bus?.emit('segment', {
id: 'seg-1',
text: '첫 문장',
timestamp: startedAt + 5_000,
isFinal: true,
} satisfies CaptionSegment)
await meeting.stopRecording()
expect(caption.stopOwners).toEqual(['meeting'])
await vi.waitFor(() => {
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
expect(row?.rawTranscript).toBeTruthy()
})
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
const lines = (row?.rawTranscript ?? '').split('\n')
expect(lines).toHaveLength(2)
expect(lines[0]).toMatch(/^\[00:0[45]\] 첫 문장$/)
expect(lines[1]).toMatch(/^\[\d{2}:\d{2}\] 마지막 문장$/)
// epoch 이 새지 않는다 (예: [29841999:45])
expect(row?.rawTranscript).not.toMatch(/\[\d{4,}:/)
})
})
describe('회의 AI 채팅', () => {
it('회의 A 의 질의응답이 회의 B 의 대화로 새지 않는다', async () => {
insertSession('meeting-a', '[00:01] A 회의 내용')
insertSession('meeting-b', '[00:01] B 회의 내용')
const meeting = getMeetingModeService()
await meeting.chatWithMeeting('meeting-a', 'A 의 결정은?')
await meeting.chatWithMeeting('meeting-b', 'B 의 결정은?')
const bMessages = llm.chatCalls[1]
expect(bMessages.some((m) => m.content.includes('A 의 결정은?'))).toBe(false)
expect(bMessages.filter((m) => m.role !== 'system')).toEqual([{ role: 'user', content: 'B 의 결정은?' }])
expect(meeting.getMeetingChatHistory('meeting-a')).toHaveLength(2)
expect(meeting.getMeetingChatHistory('meeting-b')).toHaveLength(2)
})
})
describe('Auto Polish', () => {
it('LLM 결과가 잘리면 editedTranscript 를 덮어쓰지 않고 실패한다', async () => {
const lines = Array.from({ length: 200 }, (_, i) => {
const mm = String(Math.floor(i / 60)).padStart(2, '0')
const ss = String(i % 60).padStart(2, '0')
return `[${mm}:${ss}] 이것은 회의 발언 ${i}번째 문장입니다`
})
insertSession('long-meeting', lines.join('\n'))
// 출력 한도에 걸린 것처럼 입력의 앞 절반만 돌려준다
llm.generate = async (text) => {
const kept = text.split('\n')
return { text: kept.slice(0, Math.ceil(kept.length / 2)).join('\n') }
}
await expect(getMeetingModeService().polishTranscript('long-meeting')).rejects.toMatchObject({
code: ErrorCode.MeetingPolishFailed,
})
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, 'long-meeting')).get()
expect(row?.editedTranscript ?? null).toBeNull()
})
it('긴 전사는 줄 경계 조각으로 나눠 다듬고 이어 붙인다', async () => {
const lines = Array.from({ length: 200 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i}`)
insertSession('chunked', lines.join('\n'))
const maxTokens: Array<number | undefined> = []
llm.generate = async (text, options) => {
maxTokens.push(options?.maxTokens)
return { text }
}
const polished = await getMeetingModeService().polishTranscript('chunked')
expect(maxTokens.length).toBeGreaterThan(1)
expect(maxTokens.every((n) => n !== undefined && n <= 4096)).toBe(true)
expect(polished.split('\n')).toEqual(lines)
})
})
describe('회의 삭제', () => {
it('로컬 녹음 WAV 도 지운다', async () => {
insertSession('with-audio', '[00:01] x')
const audioDir = path.join(app.getPath('userData'), 'meeting-audio')
fs.mkdirSync(audioDir, { recursive: true })
const wav = path.join(audioDir, 'with-audio.wav')
fs.writeFileSync(wav, Buffer.alloc(64))
getMeetingModeService().deleteSession('with-audio')
await vi.waitFor(() => expect(fs.existsSync(wav)).toBe(false))
})
})

View file

@ -0,0 +1,32 @@
// tests/red/meeting-summary-redteam-r1-3.test.ts
// 자막 세션 자동 요약이 자막 세션 id 로 히스토리를 찾아 항상 실패하던 버그의 회귀 테스트.
import { describe, it, expect, vi } from 'vitest'
import { useRedHarness, historyInput } from './harness'
useRedHarness()
import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService'
import { getHistoryService } from '../../src/main/services/HistoryService'
describe('MeetingSummaryService.onCaptionSessionSaved', () => {
it('자막 세션 요약에 실린 historyId 로 히스토리 행을 찾아 요약한다', async () => {
const text = '오늘 회의에서는 다음 분기 출시 일정과 담당자를 정했습니다. '.repeat(3)
const entry = getHistoryService().create(historyInput({ originalText: text, mode: 'caption' }))
const service = getMeetingSummaryService()
const summarize = vi.spyOn(service, 'summarize').mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof service.summarize>>,
)
await service.onCaptionSessionSaved({ sessionId: 'caption-session-uuid', historyId: entry.id })
expect(summarize).toHaveBeenCalledWith(entry.id)
})
it('저장되지 않은 세션(historyId 없음)은 조용히 건너뛴다', async () => {
const service = getMeetingSummaryService()
const summarize = vi.spyOn(service, 'summarize')
await service.onCaptionSessionSaved({ sessionId: 'caption-session-uuid', historyId: null })
expect(summarize).not.toHaveBeenCalled()
})
})