237 lines
9.8 KiB
TypeScript
237 lines
9.8 KiB
TypeScript
// 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<void> | 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<Array<{ role: string; content: 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: 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<typeof meetingSessions.$inferInsert> = {}): 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<void>((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)
|
|
})
|
|
})
|