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