// 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 })) 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 {} async flush(): Promise { 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') }) })