// 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>, generate: null as null | ((text: string, options?: { maxTokens?: number }) => Promise<{ text: string }>), })) vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => { const actual = await importOriginal() return { ...actual, getLocalLLMService: () => ({ isAvailable: () => true, async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator { 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 = [] 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)) }) })