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)
})
})