// 회의 전사 조립·화자 라벨·조각 나누기 (core meeting-transcript) 회귀 테스트. // 예전엔 세그먼트의 epoch 시각을 그대로 써 `[29841999:45]` 가 저장됐고, // 화자 구분을 다시 돌리면 라벨이 쌓였다. import { describe, expect, it } from 'vitest' import { applySpeakerLabels, buildTranscriptFromSegments, chunkTranscriptByLines, formatTranscriptLines, looksTruncatedRewrite, parseTranscriptLines, } from '../src/meeting-transcript' const START = 1_790_000_000_000 // epoch ms describe('buildTranscriptFromSegments', () => { it('epoch 시각을 회의 시작 기준 상대 시각으로 바꾼다', () => { const text = buildTranscriptFromSegments( [ { text: '시작합니다', timestamp: START + 5_000 }, { text: ' 다음 안건 ', timestamp: START + 65_000 }, ], START, ) expect(text).toBe('[00:05] 시작합니다\n[01:05] 다음 안건') // core 파서가 다시 읽을 수 있어야 한다 (동기화·상세 화면·화자 구분이 이 형식을 쓴다) expect(parseTranscriptLines(text).map((l) => l.timestampMs)).toEqual([5_000, 65_000]) }) it('60분을 넘는 회의도 파싱 가능한 형식으로 왕복한다', () => { const text = buildTranscriptFromSegments([{ text: '마무리', timestamp: START + 125 * 60_000 + 7_000 }], START) expect(text).toBe('[125:07] 마무리') const lines = parseTranscriptLines(text) expect(lines).toEqual([{ timestampMs: (125 * 60 + 7) * 1000, timed: true, speaker: null, text: '마무리' }]) expect(formatTranscriptLines(lines)).toBe(text) }) it('시작 전 시각은 0 으로 자르고 빈 세그먼트는 버린다', () => { const text = buildTranscriptFromSegments( [ { text: '이전', timestamp: START - 3_000 }, { text: ' ', timestamp: START + 1_000 }, ], START, ) expect(text).toBe('[00:00] 이전') }) }) describe('applySpeakerLabels', () => { const diar = [ { speaker: 'SPEAKER_00', start: 0, end: 10 }, { speaker: 'SPEAKER_01', start: 10.5, end: 30 }, ] const label = (raw: string): string => raw.replace('SPEAKER_', '화자 ') it('시각이 속한 구간의 화자로 라벨을 붙인다 (초 단위 구간)', () => { const lines = parseTranscriptLines('[00:05] 안녕하세요\n[00:20] 반갑습니다\n[00:45] 혼잣말') const labeled = applySpeakerLabels(lines, diar, label) expect(formatTranscriptLines(labeled)).toBe('[00:05] [화자 00] 안녕하세요\n[00:20] [화자 01] 반갑습니다\n[00:45] 혼잣말') }) it('이미 라벨이 있는 줄은 덧붙이지 않고 교체한다 (다시 돌려도 쌓이지 않음)', () => { const lines = parseTranscriptLines('[00:05] [화자 1] 안녕하세요') const once = applySpeakerLabels(lines, diar, label) const twice = applySpeakerLabels(parseTranscriptLines(formatTranscriptLines(once)), diar, label) expect(formatTranscriptLines(twice)).toBe('[00:05] [화자 00] 안녕하세요') }) it('시각 없는 줄은 그대로 둔다', () => { const lines = parseTranscriptLines('메모 없는 줄') expect(applySpeakerLabels(lines, diar, label)).toEqual(lines) }) it('parse→format 은 빈 줄을 버리고 공백을 정리한다', () => { expect(formatTranscriptLines(parseTranscriptLines('[00:01] 가\n\n\n[00:02] 나 '))).toBe('[00:01] 가\n[00:02] 나') }) }) describe('chunkTranscriptByLines / looksTruncatedRewrite', () => { it('줄 경계에서만 나누고 빈 줄은 버린다', () => { const text = ['[00:01] aaaa', '', '[00:02] bbbb', '[00:03] cccc'].join('\n') expect(chunkTranscriptByLines(text, 26)).toEqual(['[00:01] aaaa\n[00:02] bbbb', '[00:03] cccc']) }) it('한도보다 긴 한 줄은 그 줄 하나로 한 조각이 된다', () => { const long = `[00:01] ${'x'.repeat(50)}` expect(chunkTranscriptByLines(`${long}\n[00:02] y`, 20)).toEqual([long, '[00:02] y']) }) it('시각 줄이 크게 줄었거나 비었으면 잘린 것으로 본다', () => { const input = Array.from({ length: 10 }, (_, i) => `[00:0${i}] 문장 ${i}`).join('\n') expect(looksTruncatedRewrite(input, input)).toBe(false) expect(looksTruncatedRewrite(input, input.split('\n').slice(0, 5).join('\n'))).toBe(true) expect(looksTruncatedRewrite(input, ' ')).toBe(true) expect(looksTruncatedRewrite('시각 없는 긴 문장입니다', '시각 없는 긴 문장')).toBe(false) expect(looksTruncatedRewrite('시각 없는 아주 긴 문장입니다 정말로', '시각')).toBe(true) }) })