156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import type {
|
|
CaptionSegment,
|
|
MeetingModeStateInfo,
|
|
MeetingSessionDetail,
|
|
} from '@d3ro/core/types'
|
|
import {
|
|
applyLiveSegmentEdits,
|
|
LiveSegmentEditor,
|
|
normalizeSegmentText,
|
|
} from '../../../src/main/services/meeting/live-segment-edits'
|
|
import type { LiveMeetingPort } from '../../../src/main/services/meeting/live-segment-edits'
|
|
|
|
function seg(id: string, text: string, timestamp = 0): CaptionSegment {
|
|
return { id, text, timestamp, isFinal: true }
|
|
}
|
|
|
|
function detail(id: string, rawTranscript: string | null): MeetingSessionDetail {
|
|
return { id, rawTranscript } as unknown as MeetingSessionDetail
|
|
}
|
|
|
|
function fakePort(initial: Partial<MeetingModeStateInfo> = {}) {
|
|
let state: MeetingModeStateInfo = {
|
|
state: 'recording',
|
|
sessionId: 's1',
|
|
elapsedMs: 0,
|
|
memoCount: 0,
|
|
segmentCount: 0,
|
|
...initial,
|
|
}
|
|
const segmentListeners = new Set<(s: CaptionSegment) => void>()
|
|
const completedListeners = new Set<(d: MeetingSessionDetail) => void>()
|
|
const saveEditedTranscript = vi.fn<(sessionId: string, text: string) => void>()
|
|
const port: LiveMeetingPort = {
|
|
getStateInfo: () => state,
|
|
onSegment: (l) => {
|
|
segmentListeners.add(l)
|
|
return () => segmentListeners.delete(l)
|
|
},
|
|
onSessionCompleted: (l) => {
|
|
completedListeners.add(l)
|
|
return () => completedListeners.delete(l)
|
|
},
|
|
saveEditedTranscript,
|
|
}
|
|
return {
|
|
port,
|
|
saveEditedTranscript,
|
|
setState: (next: Partial<MeetingModeStateInfo>) => { state = { ...state, ...next } },
|
|
emitSegment: (s: CaptionSegment) => segmentListeners.forEach((l) => l(s)),
|
|
emitCompleted: (d: MeetingSessionDetail) => completedListeners.forEach((l) => l(d)),
|
|
listenerCount: () => segmentListeners.size + completedListeners.size,
|
|
}
|
|
}
|
|
|
|
describe('applyLiveSegmentEdits', () => {
|
|
const segments = [seg('a', '안녕하세요'), seg('b', '오늘 안건은'), seg('c', '예산입니다')]
|
|
const raw = '[00:01] 안녕하세요\n[00:02] 오늘 안건은\n[00:03] 예산입니다'
|
|
|
|
it('고친 세그먼트 줄의 텍스트만 바꾸고 시각 접두사는 유지한다', () => {
|
|
const out = applyLiveSegmentEdits(raw, segments, new Map([['b', '오늘 안건은 두 가지']]))
|
|
expect(out).toBe('[00:01] 안녕하세요\n[00:02] 오늘 안건은 두 가지\n[00:03] 예산입니다')
|
|
})
|
|
|
|
it('접두사 형식과 무관하게 동작한다', () => {
|
|
const odd = '[29000000:01] 안녕하세요\n[29000000:02] 오늘 안건은\n[29000000:03] 예산입니다'
|
|
expect(applyLiveSegmentEdits(odd, segments, new Map([['c', '예산 확정']]))).toBe(
|
|
'[29000000:01] 안녕하세요\n[29000000:02] 오늘 안건은\n[29000000:03] 예산 확정',
|
|
)
|
|
})
|
|
|
|
it('줄바꿈이 든 세그먼트가 있어도 올바른 줄 묶음을 바꾼다', () => {
|
|
const multi = [seg('a', '첫 줄\n둘째 줄'), seg('b', '다음')]
|
|
const text = '[00:01] 첫 줄\n둘째 줄\n[00:02] 다음'
|
|
expect(applyLiveSegmentEdits(text, multi, new Map([['b', '다음 발화']]))).toBe(
|
|
'[00:01] 첫 줄\n둘째 줄\n[00:02] 다음 발화',
|
|
)
|
|
})
|
|
|
|
it('줄 수가 세그먼트와 맞지 않으면 덮어쓰지 않고 null', () => {
|
|
expect(applyLiveSegmentEdits('[00:01] 안녕하세요', segments, new Map([['a', 'x']]))).toBeNull()
|
|
})
|
|
|
|
it('수정이 없거나 원문과 같으면 null', () => {
|
|
expect(applyLiveSegmentEdits(raw, segments, new Map())).toBeNull()
|
|
expect(applyLiveSegmentEdits(raw, segments, new Map([['a', '안녕하세요']]))).toBeNull()
|
|
})
|
|
|
|
it('줄바꿈은 공백으로 접는다', () => {
|
|
expect(normalizeSegmentText(' 하나\n 둘 \r\n셋 ')).toBe('하나 둘 셋')
|
|
})
|
|
})
|
|
|
|
describe('LiveSegmentEditor', () => {
|
|
it('녹음 중 수정은 세션 완료 시 editedTranscript 로 저장된다(원문은 그대로)', () => {
|
|
const f = fakePort()
|
|
const editor = new LiveSegmentEditor(f.port)
|
|
f.emitSegment(seg('a', '안녕하세요'))
|
|
f.emitSegment(seg('b', '오늘 안건'))
|
|
|
|
expect(editor.edit('s1', 'b', '오늘 안건은 예산')).toBe('ok')
|
|
f.setState({ state: 'processing' })
|
|
f.emitCompleted(detail('s1', '[00:01] 안녕하세요\n[00:02] 오늘 안건'))
|
|
|
|
expect(f.saveEditedTranscript).toHaveBeenCalledWith('s1', '[00:01] 안녕하세요\n[00:02] 오늘 안건은 예산')
|
|
})
|
|
|
|
it('수정이 없으면 저장하지 않는다', () => {
|
|
const f = fakePort()
|
|
new LiveSegmentEditor(f.port)
|
|
f.emitSegment(seg('a', '안녕'))
|
|
f.emitCompleted(detail('s1', '[00:01] 안녕'))
|
|
expect(f.saveEditedTranscript).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('진행 중이 아닌 세션·모르는 세그먼트·빈 내용은 거부한다', () => {
|
|
const f = fakePort()
|
|
const editor = new LiveSegmentEditor(f.port)
|
|
f.emitSegment(seg('a', '안녕'))
|
|
expect(editor.edit('other', 'a', 'x')).toBe('not-live')
|
|
expect(editor.edit('s1', 'zzz', 'x')).toBe('unknown-segment')
|
|
expect(editor.edit('s1', 'a', ' \n ')).toBe('empty')
|
|
f.setState({ state: 'idle', sessionId: null })
|
|
expect(editor.edit('s1', 'a', 'x')).toBe('not-live')
|
|
})
|
|
|
|
it('다음 세션은 이전 세션의 세그먼트·수정을 물려받지 않는다', () => {
|
|
const f = fakePort()
|
|
const editor = new LiveSegmentEditor(f.port)
|
|
f.emitSegment(seg('a', '첫 회의'))
|
|
editor.edit('s1', 'a', '첫 회의 수정')
|
|
f.setState({ sessionId: 's2' })
|
|
f.emitSegment(seg('b', '둘째 회의'))
|
|
f.emitCompleted(detail('s2', '[00:01] 둘째 회의'))
|
|
expect(f.saveEditedTranscript).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('저장이 던져도 서비스 emit 으로 예외를 올리지 않는다', () => {
|
|
const f = fakePort()
|
|
f.saveEditedTranscript.mockImplementation(() => { throw new Error('db') })
|
|
const warn = vi.fn()
|
|
const editor = new LiveSegmentEditor(f.port, { warn, info: vi.fn() })
|
|
f.emitSegment(seg('a', '안녕'))
|
|
editor.edit('s1', 'a', '반가워요')
|
|
expect(() => f.emitCompleted(detail('s1', '[00:01] 안녕'))).not.toThrow()
|
|
expect(warn).toHaveBeenCalled()
|
|
})
|
|
|
|
it('dispose 하면 구독을 해지한다', () => {
|
|
const f = fakePort()
|
|
const editor = new LiveSegmentEditor(f.port)
|
|
expect(f.listenerCount()).toBe(2)
|
|
editor.dispose()
|
|
expect(f.listenerCount()).toBe(0)
|
|
})
|
|
})
|