// tests/red/meeting-recording-redteam-r2-1.test.ts // MeetingModeService 녹음 수명주기 회귀 테스트 (실제 DB + 가짜 CaptionService / LLM): // - 정지를 두 번 눌러도(마지막 문장 flush 중) 정지·저장은 한 번 — 마지막 문장 유지, durationMs > 0, 완료 알림 1회 // - 앱 종료(shutdown) 시 녹음 중이던 전사가 저장되고 행이 'recording' 으로 남지 않는다 // - 시작 시 중단된 'recording'/'processing' 행을 닫는다 // - 페이지를 다시 열 때 진행 중인 전사·메모를 돌려준다 // - 긴 회의: 문서 생성은 조각별로 줄여 한 메시지 한도를 넘지 않고, 채팅은 관련 발췌만 넣는다 // - 회의 채팅 지우기·취소는 그 회의만 import { describe, it, expect, vi, beforeEach } from 'vitest' import { EventEmitter } from 'events' import { eq } from 'drizzle-orm' import type { CaptionSegment } from '@d3ro/core/types' import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat' import { useRedHarness } from './harness' const caption = vi.hoisted(() => ({ bus: null as EventEmitter | null, state: 'inactive' as string, stopCalls: 0, /** 정지(flush)를 붙잡아 두는 게이트 — null 이면 바로 끝난다 */ gate: null as Promise | null, })) 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 () => { caption.state = 'active' }, stop: async () => { caption.stopCalls++ if (caption.state !== 'active') return null caption.state = 'stopping' if (caption.gate) await caption.gate const segment: CaptionSegment = { id: 'seg-last', text: '마지막 문장', timestamp: Date.now(), 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(() => ({ generateInputs: [] as string[], chatCalls: [] as Array>, })) 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: async (text: string) => { llm.generateInputs.push(text) return { text: `요약(${text.length})`, model: 'fx-local' } }, cancelGeneration: vi.fn(), }), } }) useRedHarness() import { getMeetingModeService } from '../../src/main/services/MeetingModeService' import { configSet } from '../../src/main/services/ConfigService' import { getDatabase } from '../../src/main/db' import { meetingSessions } from '../../src/main/db/schema' function row(id: string) { return getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, id)).get() } function insertSession(id: string, fields: Partial = {}): void { const now = Date.now() getDatabase() .insert(meetingSessions) .values({ id, title: id, status: 'completed', startedAt: now - 60_000, createdAt: now, updatedAt: now, ...fields }) .run() } function longTranscript(lines: number): string { return Array.from({ length: lines }, (_, i) => { const mm = String(Math.floor(i / 60)).padStart(2, '0') const ss = String(i % 60).padStart(2, '0') return `[${mm}:${ss}] 이번 분기 예산과 일정에 대한 ${i}번째 발언이 이어집니다 항목 ${i}` }).join('\n') } beforeEach(() => { caption.state = 'inactive' caption.stopCalls = 0 caption.gate = null llm.generateInputs = [] llm.chatCalls = [] configSet('llmBackend', 'local') }) describe('회의 정지 — 동시에 두 번', () => { it('두 번째 정지는 진행 중인 정지를 함께 기다린다 — 마지막 문장·durationMs 유지, 완료 1회', async () => { const meeting = getMeetingModeService() const { sessionId } = await meeting.startRecording() caption.bus?.emit('segment', { id: 'seg-1', text: '첫 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment) await new Promise((resolve) => setTimeout(resolve, 5)) let release: () => void = () => undefined caption.gate = new Promise((resolve) => { release = resolve }) const completed = vi.fn() meeting.on('session-completed', completed) const first = meeting.stopRecording() const second = meeting.stopRecording() // 정지 중에는 '처리 중' — 화면의 정지 버튼이 사라진다 expect(meeting.getState()).toBe('processing') release() await Promise.all([first, second]) expect(caption.stopCalls).toBe(1) expect(completed).toHaveBeenCalledTimes(1) const saved = row(sessionId) expect(saved?.status).toBe('completed') expect(saved?.durationMs ?? 0).toBeGreaterThan(0) expect((saved?.rawTranscript ?? '').split('\n')).toHaveLength(2) expect(saved?.rawTranscript).toContain('마지막 문장') expect(meeting.getState()).toBe('idle') await expect(meeting.stopRecording()).rejects.toBeTruthy() }) }) describe('앱 종료·중단 복구', () => { it('종료(shutdown) 시 녹음 중이던 전사를 저장하고 행을 completed 로 닫는다', async () => { const meeting = getMeetingModeService() const { sessionId } = await meeting.startRecording() caption.bus?.emit('segment', { id: 'seg-a', text: '종료 전 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment) await meeting.shutdown() const saved = row(sessionId) expect(saved?.status).toBe('completed') expect(saved?.rawTranscript).toContain('종료 전 문장') expect(saved?.endedAt).not.toBeNull() expect(meeting.getState()).toBe('idle') }) it('남은 recording/processing 행을 닫는다 — 전사가 있으면 completed, 없으면 error', () => { insertSession('stuck-with-text', { status: 'recording', rawTranscript: '[00:01] 저장된 부분' }) insertSession('stuck-empty', { status: 'processing' }) insertSession('done', { status: 'completed', rawTranscript: 'x' }) expect(getMeetingModeService().recoverInterruptedSessions()).toBe(2) expect(row('stuck-with-text')?.status).toBe('completed') expect(row('stuck-with-text')?.endedAt).not.toBeNull() expect(row('stuck-empty')?.status).toBe('error') expect(row('done')?.status).toBe('completed') }) }) describe('녹음 화면 복원', () => { it('진행 중인 녹음의 세그먼트와 메모를 돌려준다', async () => { const meeting = getMeetingModeService() const { sessionId } = await meeting.startRecording() caption.bus?.emit('segment', { id: 'seg-x', text: '앞부분 발언', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment) await meeting.addMemo('중요') const live = meeting.getLiveSession() expect(live?.sessionId).toBe(sessionId) expect(live?.segments.map((s) => s.text)).toEqual(['앞부분 발언']) expect(live?.memos.map((m) => m.content)).toEqual(['중요']) await meeting.stopRecording() expect(meeting.getLiveSession()).toBeNull() }) }) describe('긴 회의의 LLM 입력', () => { it('문서 생성은 전사를 조각별로 줄여, 어떤 호출도 메시지 한도를 넘지 않는다', async () => { const transcript = longTranscript(400) expect(transcript.length).toBeGreaterThan(LLM_PROXY_CHAT_LIMITS.maxMessageChars) insertSession('long-doc', { rawTranscript: transcript }) const doc = await getMeetingModeService().generateDocument({ sessionId: 'long-doc', templateId: 'builtin-minutes' }) expect(doc.content).toBeTruthy() expect(llm.generateInputs.length).toBeGreaterThan(2) for (const input of llm.generateInputs) { expect(input.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxMessageChars) } }) it('채팅은 system 한도 안에 질문과 관련된 발췌를 넣고, 발췌라는 사실을 모델에 알린다', async () => { const lines = longTranscript(300).split('\n') lines[280] = '[04:40] 마지막에 출시 담당자는 김민수로 결정했습니다' insertSession('long-chat', { rawTranscript: lines.join('\n') }) await getMeetingModeService().chatWithMeeting('long-chat', '출시 담당자는 누구로 결정했나요?') const system = llm.chatCalls[0].find((m) => m.role === 'system')?.content ?? '' expect(system.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxSystemChars) expect(system).toContain('김민수') expect(system).toContain('발췌') }) }) describe('회의 채팅 범위', () => { it('한 회의의 지우기는 다른 회의 대화를 남긴다', async () => { insertSession('chat-a', { rawTranscript: '[00:01] A' }) insertSession('chat-b', { rawTranscript: '[00:01] B' }) const meeting = getMeetingModeService() await meeting.chatWithMeeting('chat-a', 'A?') await meeting.chatWithMeeting('chat-b', 'B?') meeting.clearMeetingChatHistory('chat-a') expect(meeting.getMeetingChatHistory('chat-a')).toEqual([]) expect(meeting.getMeetingChatHistory('chat-b')).toHaveLength(2) }) })