// tests/main/services/meeting-session-redteam-r2-1.test.ts // MeetingRecordingSession(가짜 포트)·전사 줄이기(map-reduce) 단위 테스트 import { describe, expect, it, vi } from 'vitest' import type { CaptionSegment } from '@d3ro/core/types' import { ErrorCode } from '@d3ro/core/errors' import { MeetingRecordingSession } from '../../../src/main/services/meeting/MeetingRecordingSession' import { condenseTranscriptToBudget } from '../../../src/main/services/meeting/transcript-condenser' function fakePorts() { let segmentListener: ((segment: CaptionSegment) => void) | null = null let chunkListener: ((buffer: Buffer) => void) | null = null let releaseStop: () => void = () => undefined const stop = vi.fn(async () => { await new Promise((resolve) => { releaseStop = resolve }) const last: CaptionSegment = { id: 'last', text: '마지막', timestamp: 3_000, isFinal: true } segmentListener?.(last) return { segments: [last] } }) const unsubscribeSegment = vi.fn(() => { segmentListener = null }) const ports = { caption: { onSegment: (listener: (segment: CaptionSegment) => void) => { segmentListener = listener return unsubscribeSegment }, stop, }, audio: { onChunk: (listener: (buffer: Buffer) => void) => { chunkListener = listener return () => { chunkListener = null } }, }, now: () => 10_000, } return { ports, stop, unsubscribeSegment, emitSegment: (segment: CaptionSegment) => segmentListener?.(segment), emitChunk: (buffer: Buffer) => chunkListener?.(buffer), releaseStop: () => releaseStop(), } } describe('MeetingRecordingSession', () => { it('동시에 두 번 멈춰도 자막 정지와 스냅샷은 한 번 — 같은 불변 스냅샷을 받는다', async () => { const h = fakePorts() const session = new MeetingRecordingSession('s1', 1_000, h.ports) session.attachCaption() session.attachAudio() h.emitSegment({ id: 'a', text: '첫 문장', timestamp: 2_000, isFinal: true }) h.emitChunk(Buffer.from([1, 2])) const first = session.stop() const second = session.stop() expect(session.isStopping).toBe(true) // 정지가 시작되면 메모·오디오는 더 받지 않는다 expect(session.addMemo('m', '늦은 메모')).toBeNull() h.emitChunk(Buffer.from([3])) h.releaseStop() const [a, b] = await Promise.all([first, second]) expect(h.stop).toHaveBeenCalledTimes(1) expect(a).toBe(b) expect(a.segments.map((s) => s.text)).toEqual(['첫 문장', '마지막']) expect(a.durationMs).toBe(9_000) expect([...a.pcm]).toEqual([1, 2]) expect(Object.isFrozen(a)).toBe(true) expect(h.unsubscribeSegment).toHaveBeenCalledTimes(1) }) it('자막 정지가 실패해도 그때까지의 세그먼트로 스냅샷을 만든다', async () => { const h = fakePorts() const onStopError = vi.fn() h.ports.caption.stop = vi.fn(async () => { throw new Error('stt 500') }) const session = new MeetingRecordingSession('s2', 1_000, { ...h.ports, onStopError }) session.attachCaption() h.emitSegment({ id: 'a', text: '남은 문장', timestamp: 2_000, isFinal: true }) const snapshot = await session.stop() expect(onStopError).toHaveBeenCalled() expect(snapshot.segments.map((s) => s.text)).toEqual(['남은 문장']) }) it('checkpoint 는 멈추지 않고 전사만(오디오 없이) 준다', () => { const h = fakePorts() const session = new MeetingRecordingSession('s3', 1_000, h.ports) session.attachCaption() session.attachAudio() h.emitChunk(Buffer.from([9])) h.emitSegment({ id: 'a', text: '중간', timestamp: 2_000, isFinal: true }) const snapshot = session.checkpoint({ includeAudio: false }) expect(snapshot.segments).toHaveLength(1) expect(snapshot.pcm.length).toBe(0) expect(session.isStopping).toBe(false) }) }) describe('condenseTranscriptToBudget (map-reduce)', () => { const transcript = Array.from({ length: 300 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i} `.padEnd(60, '가')).join('\n') it('한도 안이면 LLM 을 부르지 않고 원문을 돌려준다', async () => { const llm = { generate: vi.fn() } const result = await condenseTranscriptToBudget(llm, '[00:01] 짧은 회의', 8_000) expect(result).toEqual({ text: '[00:01] 짧은 회의', condensed: false, rounds: 0 }) expect(llm.generate).not.toHaveBeenCalled() }) it('한도를 넘으면 조각마다 줄이고, 각 조각 입력과 결과 모두 한도 안이다', async () => { const inputs: string[] = [] const llm = { generate: vi.fn(async (text: string) => { inputs.push(text) return { text: `요약 ${inputs.length}` } }), } const result = await condenseTranscriptToBudget(llm, transcript, 8_000) expect(transcript.length).toBeGreaterThan(8_000) expect(result.condensed).toBe(true) expect(result.text.length).toBeLessThanOrEqual(8_000) expect(inputs.length).toBeGreaterThan(1) for (const input of inputs) expect(input.length).toBeLessThanOrEqual(8_000) expect(result.text).toContain(`1/${inputs.length}`) }) it('여러 번 줄여도 넘으면 조용히 자르지 않고 LLMPromptTooLong', async () => { const llm = { generate: vi.fn(async (text: string) => ({ text })) } await expect(condenseTranscriptToBudget(llm, transcript, 8_000, { maxRounds: 1 })).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong, }) }) it('남은 자리가 없으면(메모가 한도를 다 쓰면) LLMPromptTooLong', async () => { await expect(condenseTranscriptToBudget({ generate: vi.fn() }, 'x', 0)).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong, }) }) })