226 lines
8.5 KiB
TypeScript
226 lines
8.5 KiB
TypeScript
// 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<Array<{ role: string; content: string }>>,
|
|
generate: null as null | ((text: string, options?: { maxTokens?: number }) => Promise<{ text: 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: (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<number | undefined> = []
|
|
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))
|
|
})
|
|
})
|