fix(meeting): persist transcript segment edits, restore live recording view, and route settings deep links by tab id

This commit is contained in:
Yun Chan 2026-09-28 00:54:03 +09:00
parent a8983c583a
commit a85b24d385
15 changed files with 974 additions and 69 deletions

View file

@ -0,0 +1,116 @@
import { EventEmitter } from 'events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode } from '@d3ro/core/errors'
import type { IPCResult } from '@d3ro/core/errors'
import type { CaptionSegment, MeetingModeStateInfo } from '@d3ro/core/types'
// 회의 전사 세그먼트 편집이 main 에 핸들러가 없는 채널을 호출하던 회귀
// (preload editSegment → 'meetingMode:editSegment' → No handler registered).
const invokeHandlers = vi.hoisted(
() => new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
)
type FakeMeetingService = EventEmitter & {
stateInfo: MeetingModeStateInfo
getStateInfo: () => MeetingModeStateInfo
updateTranscript: ReturnType<typeof vi.fn>
}
// vi.mock 팩토리는 모듈을 처음 import 할 때(beforeEach 안) 불리므로 이 상수는 이미 초기화되어 있다.
const service = Object.assign(new EventEmitter(), {
stateInfo: { state: 'recording', sessionId: 's1', elapsedMs: 0, memoCount: 0, segmentCount: 0 },
getStateInfo(this: FakeMeetingService): MeetingModeStateInfo {
return this.stateInfo
},
updateTranscript: vi.fn(),
}) as FakeMeetingService
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
invokeHandlers.set(channel, handler)
}),
on: vi.fn(),
},
}))
vi.mock('../../../src/main/services/MeetingModeService', () => ({
getMeetingModeService: () => service,
}))
async function invoke<T>(channel: string, ...args: unknown[]): Promise<IPCResult<T>> {
const handler = invokeHandlers.get(channel)
if (!handler) throw new Error(`No handler registered for '${channel}'`)
return (await handler({}, ...args)) as IPCResult<T>
}
function segment(id: string, text: string): CaptionSegment {
return { id, text, timestamp: 0, isFinal: true }
}
beforeEach(async () => {
vi.resetModules()
invokeHandlers.clear()
service.removeAllListeners()
service.updateTranscript.mockReset()
service.stateInfo = { state: 'recording', sessionId: 's1', elapsedMs: 0, memoCount: 0, segmentCount: 0 }
const mod = await import('../../../src/main/ipc/meeting-mode-handlers')
mod.registerMeetingModeHandlers()
})
describe('MEETING_MODE.EDIT_SEGMENT', () => {
it('핸들러가 등록되어 있다', () => {
expect(invokeHandlers.has(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT)).toBe(true)
})
it('녹음 중 수정이 세션 완료 시 editedTranscript 로 저장된다', async () => {
service.emit('segment', segment('a', '안녕하세요'))
service.emit('segment', segment('b', '오늘 안건'))
const res = await invoke(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, {
sessionId: 's1',
segmentId: 'b',
text: '오늘 안건은 예산',
})
expect(res.success).toBe(true)
service.emit('session-completed', {
id: 's1',
rawTranscript: '[00:01] 안녕하세요\n[00:02] 오늘 안건',
})
expect(service.updateTranscript).toHaveBeenCalledWith(
's1',
'[00:01] 안녕하세요\n[00:02] 오늘 안건은 예산',
)
})
it('진행 중이 아닌 세션이면 MeetingNotRecording 으로 거부한다', async () => {
service.stateInfo = { ...service.stateInfo, state: 'idle', sessionId: null }
const res = await invoke(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, {
sessionId: 's1',
segmentId: 'a',
text: 'x',
})
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.MeetingNotRecording)
})
it('모르는 세그먼트는 MeetingTranscriptUpdateFailed 로 거부한다', async () => {
const res = await invoke(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, {
sessionId: 's1',
segmentId: 'nope',
text: 'x',
})
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.MeetingTranscriptUpdateFailed)
})
it('다시 등록해도 세션 완료 리스너가 중복되지 않는다', async () => {
const mod = await import('../../../src/main/ipc/meeting-mode-handlers')
mod.registerMeetingModeHandlers()
expect(service.listenerCount('session-completed')).toBe(1)
expect(service.listenerCount('segment')).toBe(1)
})
})

View file

@ -0,0 +1,156 @@
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)
})
})