From a85b24d3852c517230f993052ae9220cbf4479a3 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:54:03 +0900 Subject: [PATCH] fix(meeting): persist transcript segment edits, restore live recording view, and route settings deep links by tab id --- .../src/main/ipc/meeting-mode-handlers.ts | 60 +++++++ .../services/meeting/live-segment-edits.ts | 146 ++++++++++++++++ .../src/renderer/components/AppLayout.tsx | 18 +- .../src/renderer/components/SettingsModal.tsx | 58 ++++--- .../components/UpgradePromptModal.tsx | 3 +- .../components/meeting/MeetingDetailTabs.tsx | 68 +++++--- .../components/meeting/transcript-segments.ts | 62 +++++++ .../src/renderer/hooks/useLicenseState.ts | 3 +- .../src/renderer/navigation/settingsTabs.ts | 59 +++++++ .../src/renderer/pages/MeetingModePage.tsx | 70 ++++++-- .../renderer/pages/meeting/live-session.ts | 44 +++++ ...meeting-edit-segment-redteam-r1-29.test.ts | 116 +++++++++++++ ...g-live-segment-edits-redteam-r1-29.test.ts | 156 ++++++++++++++++++ ...ng-renderer-segments-redteam-r1-29.test.ts | 107 ++++++++++++ .../unit/settings-tabs-redteam-r1-29.test.ts | 73 ++++++++ 15 files changed, 974 insertions(+), 69 deletions(-) create mode 100644 apps/desktop/src/main/services/meeting/live-segment-edits.ts create mode 100644 apps/desktop/src/renderer/components/meeting/transcript-segments.ts create mode 100644 apps/desktop/src/renderer/navigation/settingsTabs.ts create mode 100644 apps/desktop/src/renderer/pages/meeting/live-session.ts create mode 100644 apps/desktop/tests/main/ipc/meeting-edit-segment-redteam-r1-29.test.ts create mode 100644 apps/desktop/tests/main/services/meeting-live-segment-edits-redteam-r1-29.test.ts create mode 100644 apps/desktop/tests/unit/meeting-renderer-segments-redteam-r1-29.test.ts create mode 100644 apps/desktop/tests/unit/settings-tabs-redteam-r1-29.test.ts diff --git a/apps/desktop/src/main/ipc/meeting-mode-handlers.ts b/apps/desktop/src/main/ipc/meeting-mode-handlers.ts index 2d7f27a..339eac7 100644 --- a/apps/desktop/src/main/ipc/meeting-mode-handlers.ts +++ b/apps/desktop/src/main/ipc/meeting-mode-handlers.ts @@ -6,7 +6,11 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ErrorCode, ipcSuccess, ipcError, D3ROError } from '@d3ro/core/errors' import { getMeetingModeService } from '../services/MeetingModeService' import { getLogger } from '../services/LoggerService' +import { LiveSegmentEditor } from '../services/meeting/live-segment-edits' +import type { LiveSegmentEditResult } from '../services/meeting/live-segment-edits' import type { + CaptionSegment, + MeetingSessionDetail, MeetingAddMemoParams, MeetingGetSessionsParams, MeetingGetSessionParams, @@ -19,8 +23,47 @@ import type { const logger = getLogger('meeting-mode-handlers') +/** 렌더러 editSegment 요청 — preload `meetingMode.editSegment` 와 같은 모양 */ +interface MeetingEditSegmentParams { + sessionId: string + segmentId: string + text: string +} + +let liveSegmentEditor: LiveSegmentEditor | null = null + +/** MeetingModeService 를 LiveMeetingPort 로 감싸 실시간 세그먼트 편집기를 붙인다(재등록 시 이전 구독 해지). */ +function attachLiveSegmentEditor(): LiveSegmentEditor { + liveSegmentEditor?.dispose() + const service = getMeetingModeService() + liveSegmentEditor = new LiveSegmentEditor( + { + getStateInfo: () => service.getStateInfo(), + onSegment: (listener: (segment: CaptionSegment) => void) => { + service.on('segment', listener) + return () => { service.off('segment', listener) } + }, + onSessionCompleted: (listener: (detail: MeetingSessionDetail) => void) => { + service.on('session-completed', listener) + return () => { service.off('session-completed', listener) } + }, + saveEditedTranscript: (sessionId, editedTranscript) => + service.updateTranscript(sessionId, editedTranscript), + }, + logger, + ) + return liveSegmentEditor +} + +const EDIT_SEGMENT_FAILURES: Readonly, { code: ErrorCode; message: string }>> = { + 'not-live': { code: ErrorCode.MeetingNotRecording, message: '진행 중인 회의 세션이 아닙니다' }, + 'unknown-segment': { code: ErrorCode.MeetingTranscriptUpdateFailed, message: '수정할 발화를 찾을 수 없습니다' }, + empty: { code: ErrorCode.MeetingTranscriptUpdateFailed, message: '빈 내용으로는 수정할 수 없습니다' }, +} + export function registerMeetingModeHandlers(): void { const ch = IPC_CHANNELS.MEETING_MODE + const liveEditor = attachLiveSegmentEditor() ipcMain.handle(ch.START_RECORDING, async (_event, params?: { force?: boolean }) => { try { @@ -143,6 +186,23 @@ export function registerMeetingModeHandlers(): void { } }) + // 녹음 중 실시간 세그먼트 수정 — 세션 완료 시 editedTranscript 로 반영된다. + ipcMain.handle(ch.EDIT_SEGMENT, (_event, params: MeetingEditSegmentParams) => { + try { + const result = liveEditor.edit(params.sessionId, params.segmentId, params.text) + if (result === 'ok') return ipcSuccess(undefined) + const failure = EDIT_SEGMENT_FAILURES[result] + logger.warn(`editSegment 거부(${result}): sessionId=${params.sessionId}, segmentId=${params.segmentId}`) + return ipcError(failure.code, failure.message) + } catch (err) { + logger.error(`editSegment 실패: ${err instanceof Error ? err.message : String(err)}`) + return ipcError( + ErrorCode.MeetingTranscriptUpdateFailed, + err instanceof Error ? err.message : String(err), + ) + } + }) + ipcMain.handle(ch.GENERATE_DOCUMENT, async (_event, params: MeetingGenerateDocParams) => { try { const doc = await getMeetingModeService().generateDocument(params) diff --git a/apps/desktop/src/main/services/meeting/live-segment-edits.ts b/apps/desktop/src/main/services/meeting/live-segment-edits.ts new file mode 100644 index 0000000..69738a6 --- /dev/null +++ b/apps/desktop/src/main/services/meeting/live-segment-edits.ts @@ -0,0 +1,146 @@ +// src/main/services/meeting/live-segment-edits.ts +// 회의 녹음 중 사용자가 고친 실시간 세그먼트를 모아 두었다가, 세션이 완료되면 +// 원문 전사(rawTranscript)는 그대로 두고 수정본(editedTranscript)에 반영한다. +// +// MeetingModeService 에 직접 기대지 않고 LiveMeetingPort 로만 소통한다(DIP) — +// IPC 핸들러가 서비스 어댑터를 넘기고, 테스트는 가짜 포트를 넘긴다. + +import type { + CaptionSegment, + MeetingModeStateInfo, + MeetingSessionDetail, +} from '@d3ro/core/types' + +/** 세그먼트는 전사에서 한 줄로 저장되므로, 수정 텍스트의 줄바꿈은 공백으로 접는다. */ +export function normalizeSegmentText(text: string): string { + return text.replace(/\s*\r?\n\s*/g, ' ').trim() +} + +/** + * 원문 전사에 실시간 세그먼트 수정을 적용한다. + * + * rawTranscript 는 세그먼트마다 `<시각 접두사> ` 를 '\n' 으로 이은 문자열이다 + * (세그먼트 텍스트에 줄바꿈이 있으면 그 세그먼트가 여러 줄을 차지한다). + * 접두사 형식에 의존하지 않도록, 각 세그먼트가 차지하는 줄 묶음이 원래 텍스트로 + * 끝나는지 확인한 뒤 그 꼬리만 바꾼다. 구조가 맞지 않으면 엉뚱한 줄을 덮어쓰지 않도록 null. + * 바뀐 것이 없어도 null. + */ +export function applyLiveSegmentEdits( + rawTranscript: string, + segments: ReadonlyArray>, + edits: ReadonlyMap, +): string | null { + if (edits.size === 0 || segments.length === 0) return null + const lines = rawTranscript.split('\n') + const spans = segments.map((s) => s.text.split('\n').length) + const expectedLines = spans.reduce((sum, n) => sum + n, 0) + if (expectedLines !== lines.length) return null + + const out: string[] = [] + let cursor = 0 + let changed = false + segments.forEach((segment, idx) => { + const block = lines.slice(cursor, cursor + spans[idx]).join('\n') + cursor += spans[idx] + const edited = edits.get(segment.id) + if (edited === undefined || edited === segment.text) { + out.push(block) + return + } + if (!block.endsWith(segment.text)) { + out.push(block) + return + } + out.push(block.slice(0, block.length - segment.text.length) + edited) + changed = true + }) + // 구조 검증에서 끝을 맞추지 못한 세그먼트는 원문을 유지한다(부분 적용). + return changed ? out.join('\n') : null +} + +/** 진행 중인 회의 세션에 대한 최소 계약 — MeetingModeService 가 어댑터로 구현한다. */ +export interface LiveMeetingPort { + getStateInfo(): MeetingModeStateInfo + /** 구독하고, 해지 함수를 돌려준다 */ + onSegment(listener: (segment: CaptionSegment) => void): () => void + onSessionCompleted(listener: (detail: MeetingSessionDetail) => void): () => void + saveEditedTranscript(sessionId: string, editedTranscript: string): void +} + +export interface LiveSegmentEditLogger { + warn(message: string): void + info(message: string): void +} + +export type LiveSegmentEditResult = 'ok' | 'not-live' | 'unknown-segment' | 'empty' + +export class LiveSegmentEditor { + private sessionId: string | null = null + private segments: CaptionSegment[] = [] + private readonly edits = new Map() + private readonly unsubscribers: Array<() => void> + + constructor( + private readonly port: LiveMeetingPort, + private readonly logger?: LiveSegmentEditLogger, + ) { + this.unsubscribers = [ + port.onSegment((segment) => this.recordSegment(segment)), + port.onSessionCompleted((detail) => this.flush(detail)), + ] + } + + /** 녹음·후처리 중인 세션의 세그먼트 수정을 기록한다. 실제 저장은 세션 완료 시. */ + edit(sessionId: string, segmentId: string, text: string): LiveSegmentEditResult { + const state = this.port.getStateInfo() + if (state.state === 'idle' || state.sessionId !== sessionId) return 'not-live' + this.syncSession(sessionId) + if (!this.segments.some((s) => s.id === segmentId)) return 'unknown-segment' + const normalized = normalizeSegmentText(text) + if (!normalized) return 'empty' + this.edits.set(segmentId, normalized) + return 'ok' + } + + dispose(): void { + for (const unsubscribe of this.unsubscribers) unsubscribe() + this.unsubscribers.length = 0 + this.reset(null) + } + + private recordSegment(segment: CaptionSegment): void { + const sessionId = this.port.getStateInfo().sessionId + if (!sessionId) return + this.syncSession(sessionId) + this.segments.push(segment) + } + + private syncSession(sessionId: string): void { + if (this.sessionId !== sessionId) this.reset(sessionId) + } + + private reset(sessionId: string | null): void { + this.sessionId = sessionId + this.segments = [] + this.edits.clear() + } + + /** 서비스의 emit 안에서 불린다 — 예외가 새면 후처리가 실패로 기록되므로 여기서 삼킨다. */ + private flush(detail: MeetingSessionDetail): void { + try { + if (detail.id !== this.sessionId || this.edits.size === 0) return + const next = applyLiveSegmentEdits(detail.rawTranscript ?? '', this.segments, this.edits) + const editCount = this.edits.size + if (next === null) { + this.logger?.warn(`실시간 세그먼트 수정 ${editCount}건을 전사에 맞추지 못했습니다: sessionId=${detail.id}`) + return + } + this.port.saveEditedTranscript(detail.id, next) + this.logger?.info(`실시간 세그먼트 수정 ${editCount}건 반영: sessionId=${detail.id}`) + } catch (err) { + this.logger?.warn(`실시간 세그먼트 수정 반영 실패: ${err instanceof Error ? err.message : String(err)}`) + } finally { + this.reset(null) + } + } +} diff --git a/apps/desktop/src/renderer/components/AppLayout.tsx b/apps/desktop/src/renderer/components/AppLayout.tsx index 16e34fa..3f34d5f 100644 --- a/apps/desktop/src/renderer/components/AppLayout.tsx +++ b/apps/desktop/src/renderer/components/AppLayout.tsx @@ -24,6 +24,8 @@ import { VoiceConversationPage } from '../pages/VoiceConversationPage' import { KnowledgeBasePage } from '../pages/KnowledgeBasePage' import { MeetingModePage } from '../pages/MeetingModePage' import { SettingsModal } from './SettingsModal' +import { DEFAULT_SETTINGS_TAB, OPEN_SETTINGS_EVENT, readOpenSettingsTab } from '../navigation/settingsTabs' +import type { SettingsTabId } from '../navigation/settingsTabs' import { LicenseModal } from './LicenseModal' import { OnboardingModal } from './OnboardingModal' import { AdBanner } from './ads/AdBanner' @@ -74,7 +76,7 @@ export function AppLayout(): React.ReactElement { const { t } = useI18n() const [currentRoute, setCurrentRoute] = useState('dashboard') const [settingsOpen, setSettingsOpen] = useState(false) - const [settingsInitialTab, setSettingsInitialTab] = useState(0) + const [settingsInitialTab, setSettingsInitialTab] = useState(DEFAULT_SETTINGS_TAB) const [onboardingOpen, setOnboardingOpen] = useState(false) const [licenseModalOpen, setLicenseModalOpen] = useState(false) const [supportModalOpen, setSupportModalOpen] = useState(false) @@ -107,16 +109,12 @@ export function AppLayout(): React.ReactElement { const handleOpenLicenseModal = () => setLicenseModalOpen(true) window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal) - const handleOpenSettings = (e?: Event) => { - const customEvent = e as CustomEvent<{ tab?: number }> | undefined - if (customEvent?.detail?.tab !== undefined) { - setSettingsInitialTab(customEvent.detail.tab) - } else { - setSettingsInitialTab(0) - } + // detail.tab 은 타입 없는 DOM 경계라 검증한다 — 모르는 값이면 기본 탭(빈 설정 창 방지) + const handleOpenSettings = (e: Event) => { + setSettingsInitialTab(readOpenSettingsTab(e)) setSettingsOpen(true) } - window.addEventListener('d3ro:open-settings', handleOpenSettings as EventListener) + window.addEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings) const unsubVoiceError = window.electronAPI.voice.onError((e) => { const severity = e.severity ?? 'error' @@ -146,7 +144,7 @@ export function AppLayout(): React.ReactElement { unsubUpgradeReq() unsubVoiceError() window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal) - window.removeEventListener('d3ro:open-settings', handleOpenSettings) + window.removeEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings) } }, [t]) diff --git a/apps/desktop/src/renderer/components/SettingsModal.tsx b/apps/desktop/src/renderer/components/SettingsModal.tsx index 61495c1..0658bdd 100644 --- a/apps/desktop/src/renderer/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/components/SettingsModal.tsx @@ -38,21 +38,23 @@ import type { KeyBindingActionGroup } from '@d3ro/core/keybinding' import { auditKeyBindingMap, KEYBINDING_ACTIONS } from '@d3ro/core/keybinding' import type { ThemeMode, AppConfig, AudioDevice, LLMModel, LLMStatus, STTModel } from '@d3ro/core/types' import { useKeyBindingMap } from '../hooks/useKeyBindingMap' +import { DEFAULT_SETTINGS_TAB, SETTINGS_TAB_ORDER } from '../navigation/settingsTabs' +import type { SettingsTabId } from '../navigation/settingsTabs' interface SettingsModalProps { open: boolean - initialTab?: number + initialTab?: SettingsTabId onClose: () => void } interface TabPanelProps { children: React.ReactNode - value: number - index: number + value: SettingsTabId + tab: SettingsTabId } -function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement | null { - if (value !== index) return null +function TabPanel({ children, value, tab }: TabPanelProps): React.ReactElement | null { + if (value !== tab) return null return {children} } @@ -65,9 +67,22 @@ const ACTION_GROUP_LABEL_KEYS: Readonly> = const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window', 'input'] -export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement { +export function SettingsModal({ open, initialTab = DEFAULT_SETTINGS_TAB, onClose }: SettingsModalProps): React.ReactElement { const { t, locale, setLocale } = useI18n() - const [activeTab, setActiveTab] = useState(initialTab) + const [activeTab, setActiveTab] = useState(initialTab) + const tabMeta = useMemo>( + () => ({ + general: { label: t('settings.tabs.general'), icon: }, + audio: { label: t('settings.tabs.audio'), icon: }, + stt: { label: t('settings.tabs.stt') }, + llm: { label: t('settings.tabs.llm') }, + input: { label: t('input.tab'), icon: }, + license: { label: t('license.nav'), icon: }, + cloud: { label: t('settings.tabs.cloud') ?? 'Cloud', icon: }, + about: { label: t('settings.tabs.about') }, + }), + [t], + ) const [config, setConfig] = useState>({}) const [loading, setLoading] = useState(true) const [appVersion, setAppVersion] = useState(null) @@ -222,7 +237,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr setActiveTab(v)} + onChange={(_, v: SettingsTabId) => setActiveTab(v)} variant="scrollable" scrollButtons={false} sx={{ @@ -243,19 +258,14 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 }, }} > - } iconPosition="start" /> - } iconPosition="start" /> - - - } iconPosition="start" /> - } iconPosition="start" /> - } iconPosition="start" /> - + {SETTINGS_TAB_ORDER.map((tab) => ( + + ))} {/* ── 일반 탭 ─────────────────────────────── */} - + @@ -403,7 +413,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr {/* ── 오디오 탭 ────────────────────────────── */} - + {t('settings.microphone')} @@ -539,12 +549,12 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr {/* ── STT 탭 ───────────────────────────────── */} - + {/* ── LLM 탭 ──────────────────────────────── */} - + {/* LLM 백엔드 선택 */} @@ -802,21 +812,21 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr {/* ── 라이선스 탭 ────────────────────────────── */} - + - + {/* ── Cloud Sync 탭 ──────────────────────────── */} - + {/* ── 정보 탭 ──────────────────────────────── */} - + D3RO-VOICE diff --git a/apps/desktop/src/renderer/components/UpgradePromptModal.tsx b/apps/desktop/src/renderer/components/UpgradePromptModal.tsx index 41098cb..d994b1b 100644 --- a/apps/desktop/src/renderer/components/UpgradePromptModal.tsx +++ b/apps/desktop/src/renderer/components/UpgradePromptModal.tsx @@ -15,6 +15,7 @@ import { import { Lock, CheckCircle2 } from 'lucide-react' import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' +import { openSettings } from '../navigation/settingsTabs' import type { UpgradePromptEvent, UsageQuota } from '@d3ro/core/types' export function UpgradePromptModal(): React.ReactElement { @@ -49,7 +50,7 @@ export function UpgradePromptModal(): React.ReactElement { const openCloudSyncSettings = (): void => { setOpen(false) - window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 'cloud' } })) + openSettings('cloud') } return ( diff --git a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx index 9e08f7f..56fbdc3 100644 --- a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx +++ b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx @@ -2,7 +2,7 @@ // Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad) import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { parseTranscriptLines } from '@d3ro/core/meeting-transcript' +import { parseTranscriptSegments, applyTranscriptSegmentEdit } from './transcript-segments' import { Box, Tabs, @@ -12,6 +12,8 @@ import { Tooltip, Typography, Checkbox, + Snackbar, + Alert, } from '@mui/material' import { ArrowLeft, @@ -50,21 +52,6 @@ interface MeetingDetailTabsProps { type ViewMode = 'split' | 'transcript' | 'doc' -// 세그먼트 파싱: rawTranscript를 줄 단위로 분리 -function parseSegments( - rawTranscript: string | null, - editedTranscript: string | null, -): Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> { - // 줄 ↔ 구간 변환은 core 정본을 쓴다 — 서버 transcripts 구간도 같은 규칙으로 만들어진다. - return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({ - id: `seg-${idx}`, - timestamp: line.timestampMs, - text: line.text, - edited: false, - speaker: line.speaker ?? undefined, - })) -} - // Extract Action items from documents function extractActionItems(content: string): Array<{ id: string; text: string; done: boolean }> { const lines = content.split('\n') @@ -112,7 +99,7 @@ export function MeetingDetailTabs({ // 세그먼트 파싱 (rawTranscript / editedTranscript 기반) const segments = useMemo( - () => parseSegments(detail.rawTranscript, detail.editedTranscript), + () => parseTranscriptSegments(detail.rawTranscript, detail.editedTranscript), [detail.rawTranscript, detail.editedTranscript], ) @@ -165,14 +152,38 @@ export function MeetingDetailTabs({ }, [detail.id, titleDraft]) // ── 세그먼트 편집 ── + // 세그먼트는 전사 줄에서 파생되므로, 고친 줄로 전사 전체를 재조립해 updateTranscript 로 저장한다. + // 화면은 낙관적으로 먼저 바꾸고, 저장이 실패하면 이전 수정본으로 되돌린다. + const [segmentEditError, setSegmentEditError] = useState(null) + const latestTranscriptRef = useRef<{ edited: string | null; raw: string | null }>({ + edited: detail.editedTranscript, + raw: detail.rawTranscript, + }) + latestTranscriptRef.current = { edited: detail.editedTranscript, raw: detail.rawTranscript } + const handleEditSegment = useCallback( async (segmentId: string, newText: string) => { - await window.electronAPI.meetingMode.editSegment({ - sessionId: detail.id, - segmentId, - text: newText, - }) - setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript })) + const { edited: previous, raw } = latestTranscriptRef.current + const next = applyTranscriptSegmentEdit(previous || raw, segmentId, newText) + if (next === null) return + latestTranscriptRef.current = { edited: next, raw } + setDetail((prev) => ({ ...prev, editedTranscript: next })) + const rollback = (message: string): void => { + if (latestTranscriptRef.current.edited === next) { + latestTranscriptRef.current = { edited: previous, raw } + setDetail((prev) => (prev.editedTranscript === next ? { ...prev, editedTranscript: previous } : prev)) + } + setSegmentEditError(message) + } + try { + const resp = await window.electronAPI.meetingMode.updateTranscript({ + sessionId: detail.id, + editedTranscript: next, + }) + if (!resp.success) rollback(resp.error.message) + } catch (err: unknown) { + rollback(err instanceof Error ? err.message : String(err)) + } }, [detail.id], ) @@ -639,6 +650,17 @@ export function MeetingDetailTabs({ generating={generating} onGenerate={handleGenerate} /> + + setSegmentEditError(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSegmentEditError(null)} sx={{ width: '100%' }}> + {segmentEditError} + + ) } diff --git a/apps/desktop/src/renderer/components/meeting/transcript-segments.ts b/apps/desktop/src/renderer/components/meeting/transcript-segments.ts new file mode 100644 index 0000000..ac5c287 --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/transcript-segments.ts @@ -0,0 +1,62 @@ +// src/renderer/components/meeting/transcript-segments.ts +// 회의 상세 뷰의 전사 ↔ 편집 가능한 세그먼트 변환. +// 줄 형식 자체는 core `meeting-transcript` 가 정본이고, 여기서는 세그먼트 id 규칙과 +// "한 세그먼트를 고친 전사 텍스트 재조립"만 담당한다. + +import { parseTranscriptLines, formatTranscriptLines } from '@d3ro/core/meeting-transcript' + +export interface TranscriptSegmentView { + id: string + timestamp: number + text: string + edited: boolean + speaker?: string +} + +const SEGMENT_ID_PREFIX = 'seg-' + +export function transcriptSegmentId(index: number): string { + return `${SEGMENT_ID_PREFIX}${index}` +} + +/** `seg-` → n. 형식이 아니면 null. */ +export function transcriptSegmentIndex(segmentId: string): number | null { + if (!segmentId.startsWith(SEGMENT_ID_PREFIX)) return null + const raw = segmentId.slice(SEGMENT_ID_PREFIX.length) + if (!/^\d+$/.test(raw)) return null + return Number(raw) +} + +/** 수정본이 있으면 수정본을, 없으면 원문을 세그먼트로 나눈다. */ +export function parseTranscriptSegments( + rawTranscript: string | null, + editedTranscript: string | null, +): TranscriptSegmentView[] { + return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({ + id: transcriptSegmentId(idx), + timestamp: line.timestampMs, + text: line.text, + edited: false, + speaker: line.speaker ?? undefined, + })) +} + +/** + * 세그먼트 하나의 텍스트를 바꾼 전사 전체를 돌려준다(시각·화자 표기는 유지). + * 세그먼트를 찾지 못했거나, 내용이 비었거나, 바뀐 것이 없으면 null. + */ +export function applyTranscriptSegmentEdit( + transcript: string | null, + segmentId: string, + newText: string, +): string | null { + const index = transcriptSegmentIndex(segmentId) + if (index === null) return null + const lines = parseTranscriptLines(transcript) + const target = lines[index] + if (!target) return null + const text = newText.replace(/\s*\r?\n\s*/g, ' ').trim() + if (!text || text === target.text) return null + const next = lines.map((line, idx) => (idx === index ? { ...line, text } : line)) + return formatTranscriptLines(next) +} diff --git a/apps/desktop/src/renderer/hooks/useLicenseState.ts b/apps/desktop/src/renderer/hooks/useLicenseState.ts index 212eedb..375a01d 100644 --- a/apps/desktop/src/renderer/hooks/useLicenseState.ts +++ b/apps/desktop/src/renderer/hooks/useLicenseState.ts @@ -8,6 +8,7 @@ import type { UsageQuota, TierComparison, } from '@d3ro/core/types' +import { openSettings } from '../navigation/settingsTabs' interface CloudState { authenticated: boolean @@ -69,7 +70,7 @@ export function useLicenseState(): LicenseState { }, []) const openCloudSettings = useCallback(() => { - window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 'cloud' } })) + openSettings('cloud') }, []) return { diff --git a/apps/desktop/src/renderer/navigation/settingsTabs.ts b/apps/desktop/src/renderer/navigation/settingsTabs.ts new file mode 100644 index 0000000..0d21e04 --- /dev/null +++ b/apps/desktop/src/renderer/navigation/settingsTabs.ts @@ -0,0 +1,59 @@ +// src/renderer/navigation/settingsTabs.ts +// 설정 창 딥링크의 단일 진입점. +// 호출자는 SettingsModal 의 탭 순서(숫자 인덱스)가 아니라 이 탭 식별자에 의존한다. +// 'd3ro:open-settings' 이벤트를 보내는 곳(openSettings)과 읽는 곳(readOpenSettingsTab)이 모두 여기 있다. + +/** SettingsModal 이 렌더하는 탭 순서 — 탭을 옮기면 이 배열만 바꾼다. */ +export const SETTINGS_TAB_ORDER = [ + 'general', + 'audio', + 'stt', + 'llm', + 'input', + 'license', + 'cloud', + 'about', +] as const + +export type SettingsTabId = (typeof SETTINGS_TAB_ORDER)[number] + +export const DEFAULT_SETTINGS_TAB: SettingsTabId = 'general' + +export const OPEN_SETTINGS_EVENT = 'd3ro:open-settings' + +export interface OpenSettingsDetail { + tab?: SettingsTabId +} + +export function isSettingsTabId(value: unknown): value is SettingsTabId { + return typeof value === 'string' && (SETTINGS_TAB_ORDER as readonly string[]).includes(value) +} + +export function settingsTabIndex(tab: SettingsTabId): number { + return SETTINGS_TAB_ORDER.indexOf(tab) +} + +/** + * 이벤트 detail.tab 을 탭 식별자로 바꾼다. 타입 없는 DOM 이벤트 경계라 검증한다. + * 예전 호출부가 보내던 숫자 인덱스도 범위 안이면 받아 준다. 그 밖은 기본 탭. + */ +export function resolveSettingsTab(value: unknown): SettingsTabId { + if (isSettingsTabId(value)) return value + if (typeof value === 'number' && Number.isInteger(value)) { + return SETTINGS_TAB_ORDER[value] ?? DEFAULT_SETTINGS_TAB + } + return DEFAULT_SETTINGS_TAB +} + +/** 'd3ro:open-settings' 이벤트에서 열 탭을 읽는다(detail 이 없으면 기본 탭). */ +export function readOpenSettingsTab(event: Event): SettingsTabId { + const detail: unknown = event instanceof CustomEvent ? event.detail : undefined + if (typeof detail !== 'object' || detail === null) return DEFAULT_SETTINGS_TAB + return resolveSettingsTab((detail as { tab?: unknown }).tab) +} + +/** 설정 창을 연다. 탭을 주지 않으면 기본 탭. */ +export function openSettings(tab?: SettingsTabId, target: EventTarget = window): void { + const detail: OpenSettingsDetail = tab === undefined ? {} : { tab } + target.dispatchEvent(new CustomEvent(OPEN_SETTINGS_EVENT, { detail })) +} diff --git a/apps/desktop/src/renderer/pages/MeetingModePage.tsx b/apps/desktop/src/renderer/pages/MeetingModePage.tsx index 4d346a5..081ed61 100644 --- a/apps/desktop/src/renderer/pages/MeetingModePage.tsx +++ b/apps/desktop/src/renderer/pages/MeetingModePage.tsx @@ -49,6 +49,13 @@ import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs' import { EditableSegment } from '../components/meeting/EditableSegment' import { PageHeader, EmptyStateCard } from '../components/shared' import { isImeComposingEvent } from '../utils/keyboard' +import { openSettings } from '../navigation/settingsTabs' +import { + isActiveMeetingState, + recordingStartFromElapsed, + replaceSegmentText, + rollbackSegmentText, +} from './meeting/live-session' import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { @@ -98,6 +105,10 @@ export function MeetingModePage(): React.ReactElement { // 녹음 뷰 상태 const [stateInfo, setStateInfo] = useState(null) const [segments, setSegments] = useState([]) + const segmentsRef = useRef(segments) + segmentsRef.current = segments + /** startRecording 응답의 세션 id — stateChanged 이벤트보다 먼저 편집이 와도 쓸 수 있게 */ + const [liveSessionId, setLiveSessionId] = useState(null) const [memos, setMemos] = useState([]) const [memoInput, setMemoInput] = useState('') const [elapsedMs, setElapsedMs] = useState(0) @@ -134,6 +145,28 @@ export function MeetingModePage(): React.ReactElement { checkPreflight() }, [checkPreflight]) + // ── 진행 중 세션 복원 ── + // 다른 페이지로 갔다 오면 이 페이지는 새로 마운트된다. main 은 녹음을 계속하므로 + // 상태를 물어 녹음 뷰(정지 버튼·진행률)를 되살린다. + useEffect(() => { + let cancelled = false + window.electronAPI.meetingMode + .getState() + .then((resp) => { + if (cancelled || !resp.success || !isActiveMeetingState(resp.data.state)) return + const info = resp.data + recordingStartRef.current = recordingStartFromElapsed(info.elapsedMs, Date.now()) + setStateInfo(info) + setLiveSessionId(info.sessionId) + setElapsedMs(info.elapsedMs) + setView('recording') + }) + .catch(() => { /* 복원 실패 시 목록 뷰 유지 */ }) + return () => { + cancelled = true + } + }, []) + // ── 에러 파서 ── const parseMeetingError = useCallback((errMessage?: string, errCode?: number | string): MeetingErrorInfo => { const msg = (errMessage ?? '').toLowerCase() @@ -268,7 +301,8 @@ export function MeetingModePage(): React.ReactElement { // ── 경과 시간 타이머 ── useEffect(() => { if (view === 'recording') { - recordingStartRef.current = Date.now() + // 시작 시각은 뷰로 들어오는 쪽(새 녹음 / 세션 복원)이 정한다 + if (!recordingStartRef.current) recordingStartRef.current = Date.now() elapsedTimerRef.current = setInterval(() => { setElapsedMs(Date.now() - recordingStartRef.current) }, 1000) @@ -288,6 +322,8 @@ export function MeetingModePage(): React.ReactElement { try { const resp = await window.electronAPI.meetingMode.startRecording({ force }) if (resp.success) { + recordingStartRef.current = Date.now() + setLiveSessionId(resp.data.sessionId) setSegments([]) setMemos([]) setElapsedMs(0) @@ -311,20 +347,33 @@ export function MeetingModePage(): React.ReactElement { }, []) // ── 실시간 전사 세그먼트 즉시 수정 핸들러 ── + // 화면에 먼저 반영하고, main 이 거부하거나 호출이 실패하면 되돌린 뒤 알린다. + const liveSessionIdForEdit = stateInfo?.sessionId ?? liveSessionId const handleLiveEditSegment = useCallback( async (segmentId: string, newText: string) => { - setSegments((prev) => - prev.map((s) => (s.id === segmentId ? { ...s, text: newText } : s)), - ) - if (stateInfo?.sessionId) { - await window.electronAPI.meetingMode.editSegment({ - sessionId: stateInfo.sessionId, + const previousText = segmentsRef.current.find((s) => s.id === segmentId)?.text + if (previousText === undefined || previousText === newText) return + setSegments((prev) => replaceSegmentText(prev, segmentId, newText)) + const rollback = (message: string): void => { + setSegments((prev) => rollbackSegmentText(prev, segmentId, newText, previousText)) + setSnackbarMsg(message) + } + if (!liveSessionIdForEdit) { + rollback(t('meeting.error')) + return + } + try { + const resp = await window.electronAPI.meetingMode.editSegment({ + sessionId: liveSessionIdForEdit, segmentId, text: newText, }) + if (!resp.success) rollback(resp.error.message) + } catch (err: unknown) { + rollback(err instanceof Error ? err.message : String(err)) } }, - [stateInfo?.sessionId], + [liveSessionIdForEdit, t], ) const isAddingMemoRef = useRef(false) @@ -507,7 +556,7 @@ export function MeetingModePage(): React.ReactElement { size="small" onClick={() => { setErrorInfo(null) - window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 2 } })) + openSettings('stt') }} leadingIcon={} > @@ -521,7 +570,7 @@ export function MeetingModePage(): React.ReactElement { size="small" onClick={() => { setErrorInfo(null) - window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 1 } })) + openSettings('audio') }} leadingIcon={} > @@ -706,6 +755,7 @@ export function MeetingModePage(): React.ReactElement { )} {snackbar} + {errorDialog} ) } diff --git a/apps/desktop/src/renderer/pages/meeting/live-session.ts b/apps/desktop/src/renderer/pages/meeting/live-session.ts new file mode 100644 index 0000000..8b19ce8 --- /dev/null +++ b/apps/desktop/src/renderer/pages/meeting/live-session.ts @@ -0,0 +1,44 @@ +// src/renderer/pages/meeting/live-session.ts +// 회의 녹음 뷰의 순수 정책 — 페이지가 다시 마운트될 때 진행 중 세션 복원, 실시간 세그먼트 편집. + +import type { CaptionSegment, MeetingModeState } from '@d3ro/core/types' + +/** main 에서 녹음 또는 후처리가 진행 중이면 녹음 뷰를 보여 줘야 한다(정지·진행률 표시). */ +export function isActiveMeetingState(state: MeetingModeState | null | undefined): boolean { + return state === 'recording' || state === 'processing' +} + +/** main 이 알려 준 경과 시간으로 녹음 시작 시각을 되짚는다(경과 타이머·세그먼트 상대 시각 기준). */ +export function recordingStartFromElapsed(elapsedMs: number, now: number): number { + return now - Math.max(0, Number.isFinite(elapsedMs) ? elapsedMs : 0) +} + +/** 세그먼트 하나의 텍스트를 바꾼 새 배열. 해당 세그먼트가 없으면 원래 배열 그대로. */ +export function replaceSegmentText( + segments: readonly CaptionSegment[], + segmentId: string, + text: string, +): CaptionSegment[] { + let changed = false + const next = segments.map((s) => { + if (s.id !== segmentId || s.text === text) return s + changed = true + return { ...s, text } + }) + return changed ? next : (segments as CaptionSegment[]) +} + +/** + * 낙관적 편집을 되돌린다 — 그 사이 같은 세그먼트를 다시 고쳤다면(현재 텍스트가 실패한 편집과 다르면) + * 최신 편집을 덮어쓰지 않는다. + */ +export function rollbackSegmentText( + segments: readonly CaptionSegment[], + segmentId: string, + failedText: string, + previousText: string, +): CaptionSegment[] { + const current = segments.find((s) => s.id === segmentId) + if (!current || current.text !== failedText) return segments as CaptionSegment[] + return replaceSegmentText(segments, segmentId, previousText) +} diff --git a/apps/desktop/tests/main/ipc/meeting-edit-segment-redteam-r1-29.test.ts b/apps/desktop/tests/main/ipc/meeting-edit-segment-redteam-r1-29.test.ts new file mode 100644 index 0000000..38d8e27 --- /dev/null +++ b/apps/desktop/tests/main/ipc/meeting-edit-segment-redteam-r1-29.test.ts @@ -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 unknown>(), +) + +type FakeMeetingService = EventEmitter & { + stateInfo: MeetingModeStateInfo + getStateInfo: () => MeetingModeStateInfo + updateTranscript: ReturnType +} + +// 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(channel: string, ...args: unknown[]): Promise> { + const handler = invokeHandlers.get(channel) + if (!handler) throw new Error(`No handler registered for '${channel}'`) + return (await handler({}, ...args)) as IPCResult +} + +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) + }) +}) diff --git a/apps/desktop/tests/main/services/meeting-live-segment-edits-redteam-r1-29.test.ts b/apps/desktop/tests/main/services/meeting-live-segment-edits-redteam-r1-29.test.ts new file mode 100644 index 0000000..9416497 --- /dev/null +++ b/apps/desktop/tests/main/services/meeting-live-segment-edits-redteam-r1-29.test.ts @@ -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 = {}) { + 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) => { 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) + }) +}) diff --git a/apps/desktop/tests/unit/meeting-renderer-segments-redteam-r1-29.test.ts b/apps/desktop/tests/unit/meeting-renderer-segments-redteam-r1-29.test.ts new file mode 100644 index 0000000..28af5d9 --- /dev/null +++ b/apps/desktop/tests/unit/meeting-renderer-segments-redteam-r1-29.test.ts @@ -0,0 +1,107 @@ +import fs from 'fs' +import path from 'path' +import { describe, expect, it } from 'vitest' +import type { CaptionSegment } from '@d3ro/core/types' +import { + applyTranscriptSegmentEdit, + parseTranscriptSegments, + transcriptSegmentIndex, +} from '../../src/renderer/components/meeting/transcript-segments' +import { + isActiveMeetingState, + recordingStartFromElapsed, + replaceSegmentText, + rollbackSegmentText, +} from '../../src/renderer/pages/meeting/live-session' + +describe('상세 뷰 세그먼트 편집 — 전사 재조립', () => { + const raw = '[00:01] [화자 1] 안녕하세요\n[00:05] 오늘 안건은\n[01:10] 예산입니다' + + it('세그먼트 id 는 줄 순번이다', () => { + const segs = parseTranscriptSegments(raw, null) + expect(segs.map((s) => s.id)).toEqual(['seg-0', 'seg-1', 'seg-2']) + expect(segs[0].speaker).toBe('화자 1') + expect(transcriptSegmentIndex('seg-12')).toBe(12) + expect(transcriptSegmentIndex('x-1')).toBeNull() + expect(transcriptSegmentIndex('seg-')).toBeNull() + }) + + it('고친 줄만 바뀐 전사를 돌려준다(시각·화자 유지)', () => { + const next = applyTranscriptSegmentEdit(raw, 'seg-1', '오늘 안건은 두 가지') + expect(next).toBe('[00:01] [화자 1] 안녕하세요\n[00:05] 오늘 안건은 두 가지\n[01:10] 예산입니다') + // 재조립한 전사를 다시 나누면 수정이 그대로 보인다 — 원문으로 되돌아가지 않는다 + expect(parseTranscriptSegments(raw, next)[1].text).toBe('오늘 안건은 두 가지') + }) + + it('기존 수정본 위에 이어서 고친다', () => { + const first = applyTranscriptSegmentEdit(raw, 'seg-0', '반갑습니다') + const second = applyTranscriptSegmentEdit(first, 'seg-2', '예산 확정') + const texts = parseTranscriptSegments(raw, second).map((s) => s.text) + expect(texts).toEqual(['반갑습니다', '오늘 안건은', '예산 확정']) + }) + + it('없는 세그먼트·빈 내용·같은 내용이면 null', () => { + expect(applyTranscriptSegmentEdit(raw, 'seg-9', 'x')).toBeNull() + expect(applyTranscriptSegmentEdit(raw, 'bogus', 'x')).toBeNull() + expect(applyTranscriptSegmentEdit(raw, 'seg-0', ' ')).toBeNull() + expect(applyTranscriptSegmentEdit(raw, 'seg-0', '안녕하세요')).toBeNull() + }) + + it('줄바꿈은 한 줄로 접어 다른 세그먼트를 만들지 않는다', () => { + const next = applyTranscriptSegmentEdit(raw, 'seg-1', '둘\n셋') + expect(parseTranscriptSegments(raw, next)).toHaveLength(3) + }) +}) + +describe('녹음 뷰 — 세션 복원과 실시간 편집 롤백', () => { + const segs: CaptionSegment[] = [ + { id: 'a', text: '하나', timestamp: 0, isFinal: true }, + { id: 'b', text: '둘', timestamp: 0, isFinal: true }, + ] + + it('녹음·후처리 중이면 녹음 뷰를 복원한다', () => { + expect(isActiveMeetingState('recording')).toBe(true) + expect(isActiveMeetingState('processing')).toBe(true) + expect(isActiveMeetingState('idle')).toBe(false) + expect(isActiveMeetingState(null)).toBe(false) + }) + + it('경과 시간으로 시작 시각을 되짚는다', () => { + expect(recordingStartFromElapsed(5_000, 100_000)).toBe(95_000) + expect(recordingStartFromElapsed(-1, 100_000)).toBe(100_000) + expect(recordingStartFromElapsed(Number.NaN, 100_000)).toBe(100_000) + }) + + it('실패한 편집만 되돌리고, 그 사이 다시 고친 내용은 덮어쓰지 않는다', () => { + const edited = replaceSegmentText(segs, 'b', '둘 수정') + expect(edited[1].text).toBe('둘 수정') + expect(rollbackSegmentText(edited, 'b', '둘 수정', '둘')[1].text).toBe('둘') + + const reEdited = replaceSegmentText(edited, 'b', '둘 재수정') + expect(rollbackSegmentText(reEdited, 'b', '둘 수정', '둘')[1].text).toBe('둘 재수정') + }) +}) + +describe('MeetingModePage 구조 회귀', () => { + const source = fs.readFileSync( + path.join(__dirname, '../../src/renderer/pages/MeetingModePage.tsx'), + 'utf-8', + ) + + it('목록 뷰도 오류 다이얼로그(강제 초기화 버튼)를 렌더한다', () => { + const start = source.search(/if \(view === 'list'\) \{\s*return \(/) + expect(start).toBeGreaterThan(-1) + const end = source.indexOf("if (view === 'recording')", start) + expect(end).toBeGreaterThan(start) + const listBranch = source.slice(start, end) + expect(listBranch).toContain('{errorDialog}') + }) + + it('마운트 시 main 의 회의 상태를 물어 진행 중 녹음을 복원한다', () => { + expect(source).toMatch(/meetingMode\s*\.getState\(\)/) + }) + + it('설정 딥링크에 숫자 인덱스를 쓰지 않는다', () => { + expect(source).not.toMatch(/detail:\s*\{\s*tab:\s*\d/) + }) +}) diff --git a/apps/desktop/tests/unit/settings-tabs-redteam-r1-29.test.ts b/apps/desktop/tests/unit/settings-tabs-redteam-r1-29.test.ts new file mode 100644 index 0000000..49816a9 --- /dev/null +++ b/apps/desktop/tests/unit/settings-tabs-redteam-r1-29.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { + OPEN_SETTINGS_EVENT, + SETTINGS_TAB_ORDER, + isSettingsTabId, + openSettings, + readOpenSettingsTab, + resolveSettingsTab, + settingsTabIndex, +} from '../../src/renderer/navigation/settingsTabs' +import type { SettingsTabId } from '../../src/renderer/navigation/settingsTabs' + +// 로그인 CTA 가 detail.tab='cloud' 를 보내면 AppLayout 이 그대로 숫자 인덱스 자리에 넣어 +// 아무 패널도 없는 빈 설정 창이 열리던 회귀. + +function capture(target: EventTarget): () => SettingsTabId | null { + let received: SettingsTabId | null = null + target.addEventListener(OPEN_SETTINGS_EVENT, (e) => { + received = readOpenSettingsTab(e) + }) + return () => received +} + +describe('settingsTabs', () => { + it('탭 순서는 SettingsModal 의 8개 탭과 같고 cloud 는 6번째(0 기준)다', () => { + expect(SETTINGS_TAB_ORDER).toEqual(['general', 'audio', 'stt', 'llm', 'input', 'license', 'cloud', 'about']) + expect(settingsTabIndex('cloud')).toBe(6) + expect(settingsTabIndex('stt')).toBe(2) + expect(settingsTabIndex('audio')).toBe(1) + }) + + it('알려진 id 는 그대로, 모르는 값은 general 로 바꾼다', () => { + expect(resolveSettingsTab('cloud')).toBe('cloud') + expect(resolveSettingsTab('nope')).toBe('general') + expect(resolveSettingsTab(undefined)).toBe('general') + expect(resolveSettingsTab(null)).toBe('general') + expect(resolveSettingsTab(99)).toBe('general') + expect(resolveSettingsTab(1.5)).toBe('general') + }) + + it('예전 숫자 인덱스 딥링크도 같은 탭으로 연다', () => { + expect(resolveSettingsTab(2)).toBe('stt') + expect(resolveSettingsTab(1)).toBe('audio') + }) + + it('isSettingsTabId 는 문자열 id 만 통과시킨다', () => { + expect(isSettingsTabId('about')).toBe(true) + expect(isSettingsTabId(0)).toBe(false) + expect(isSettingsTabId('Cloud')).toBe(false) + }) + + it("openSettings('cloud') 를 받은 쪽은 cloud 탭을 연다", () => { + const target = new EventTarget() + const received = capture(target) + openSettings('cloud', target) + expect(received()).toBe('cloud') + }) + + it('탭 없이 열거나 detail 없는 이벤트는 general', () => { + const target = new EventTarget() + const received = capture(target) + openSettings(undefined, target) + expect(received()).toBe('general') + target.dispatchEvent(new Event(OPEN_SETTINGS_EVENT)) + expect(received()).toBe('general') + }) + + it("예전 문자열 'cloud' detail 을 보내던 이벤트도 빈 창이 아니라 cloud 로 읽힌다", () => { + const event = new CustomEvent(OPEN_SETTINGS_EVENT, { detail: { tab: 'cloud' } }) + expect(readOpenSettingsTab(event)).toBe('cloud') + expect(readOpenSettingsTab(new CustomEvent(OPEN_SETTINGS_EVENT, { detail: { tab: 'bogus' } }))).toBe('general') + }) +})