import { describe, it, expect, vi } from 'vitest' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ErrorCode } from '@d3ro/core/errors' import { KEYBINDING_ACTIONS, createDefaultBindingMap, findActionSpec, } from '@d3ro/core/keybinding' import type { KeyBinding, KeyBindingMap, KeyBindingValidationResult, } from '@d3ro/core/types' import { configGet } from '../../src/main/services/ConfigService' import { registerKeyBindingHandlers } from '../../src/main/ipc/keybinding-handlers' import { registerCaptionHandlers } from '../../src/main/ipc/caption-handlers' import { registerRAGHandlers } from '../../src/main/ipc/rag-handlers' import { registerVoiceConversationHandlers } from '../../src/main/ipc/voice-conversation-handlers' import { registerVoiceActionHandlers } from '../../src/main/ipc/voice-action-handlers' import { registerFileTranscriptionHandlers } from '../../src/main/ipc/file-transcription-handlers' import { registerMeetingModeHandlers } from '../../src/main/ipc/meeting-mode-handlers' import { registerTemplateHandlers } from '../../src/main/ipc/template-handlers' import { registerMeetingDocTemplateHandlers } from '../../src/main/ipc/meeting-doc-template-handlers' import { invokeIpc, useRedHarness } from './harness' const { keyBindingService } = vi.hoisted(() => ({ keyBindingService: { loadFromConfig: vi.fn(), start: vi.fn(), stop: vi.fn(), on: vi.fn(), off: vi.fn(), }, })) vi.mock('../../src/main/services/KeyBindingService', () => ({ getKeyBindingService: () => keyBindingService, })) useRedHarness() /** Ctrl + A — 시스템 예약 조합이라 검증에서 거부되어야 한다 */ const RESERVED_BINDING: KeyBinding = { device: 'keyboard', code: 0x41, ctrl: true, alt: false, shift: false, meta: false, } /** Ctrl + Shift + F9 — 예약되지 않은 유효한 조합 */ const FREE_BINDING: KeyBinding = { device: 'keyboard', code: 0x78, ctrl: true, alt: false, shift: true, meta: false, } describe('유스케이스: 키바인딩 / 캡션 / RAG / 대화 / 액션 / 파일전사 / 회의 IPC', () => { it('키바인딩 맵을 읽으면 모든 액션이 들어 있다', async () => { registerKeyBindingHandlers() const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.GET_MAP) expect(get.success).toBe(true) if (get.success) { for (const action of KEYBINDING_ACTIONS) { expect(get.data[action.id]).toEqual(action.defaultBindings) } } }) it('액션 바인딩을 교체하면 맵에 반영되고 서비스가 다시 읽는다', async () => { registerKeyBindingHandlers() const set = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'dictation', bindings: [FREE_BINDING], }) expect(set.success).toBe(true) expect(keyBindingService.loadFromConfig).toHaveBeenCalled() const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.GET_MAP) if (get.success) expect(get.data.dictation).toEqual([FREE_BINDING]) }) it('시스템 예약 조합은 HotkeySystemReserved로 거부된다', async () => { registerKeyBindingHandlers() const set = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'dictation', bindings: [RESERVED_BINDING], }) expect(set.success).toBe(false) if (!set.success) expect(set.error.code).toBe(ErrorCode.HotkeySystemReserved) }) it('다른 액션이 쓰는 바인딩은 HotkeyConflict로 거부된다', async () => { registerKeyBindingHandlers() const ok = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'caption', bindings: [FREE_BINDING], }) expect(ok.success).toBe(true) const conflict = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'command', bindings: [FREE_BINDING], }) expect(conflict.success).toBe(false) if (!conflict.success) expect(conflict.error.code).toBe(ErrorCode.HotkeyConflict) }) it('바인딩 사전 검사는 유효성과 충돌을 함께 돌려준다', async () => { registerKeyBindingHandlers() const reserved = await invokeIpc( IPC_CHANNELS.KEYBINDING.VALIDATE, { actionId: 'dictation', binding: RESERVED_BINDING }, ) expect(reserved.success).toBe(true) if (reserved.success) { expect(reserved.data.validation.valid).toBe(false) expect(reserved.data.validation.reason).toBe('system-reserved') } const free = await invokeIpc( IPC_CHANNELS.KEYBINDING.VALIDATE, { actionId: 'dictation', binding: FREE_BINDING }, ) if (free.success) { expect(free.data.validation.valid).toBe(true) expect(free.data.conflicts).toEqual([]) } }) it('액션 하나를 기본값으로 되돌린다', async () => { registerKeyBindingHandlers() await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'dictation', bindings: [FREE_BINDING], }) const reset = await invokeIpc(IPC_CHANNELS.KEYBINDING.RESET_ACTION, { actionId: 'dictation' }) expect(reset.success).toBe(true) const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.GET_MAP) if (get.success) { expect(get.data.dictation).toEqual(findActionSpec('dictation')?.defaultBindings) } }) it('전체를 기본값으로 되돌린다', async () => { registerKeyBindingHandlers() await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'caption', bindings: [FREE_BINDING], }) const reset = await invokeIpc(IPC_CHANNELS.KEYBINDING.RESET_ALL) expect(reset.success).toBe(true) const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.GET_MAP) if (get.success) expect(get.data).toEqual(createDefaultBindingMap()) }) it('알 수 없는 액션 id는 거부된다', async () => { registerKeyBindingHandlers() const res = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'nope', bindings: [FREE_BINDING], }) expect(res.success).toBe(false) }) it('키바인딩 활성 토글을 끈다', async () => { registerKeyBindingHandlers() const set = await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_ENABLED, { enabled: false }) expect(set.success).toBe(true) const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.IS_ENABLED) expect(get.success).toBe(true) if (get.success) expect(get.data).toBe(false) expect(configGet('hotkeyEnabled')).toBe(false) expect(keyBindingService.stop).toHaveBeenCalled() }) it('키바인딩을 다시 켜면 등록을 다시 읽고 후킹을 시작한다', async () => { registerKeyBindingHandlers() keyBindingService.loadFromConfig.mockClear() await invokeIpc(IPC_CHANNELS.KEYBINDING.SET_ENABLED, { enabled: true }) const get = await invokeIpc(IPC_CHANNELS.KEYBINDING.IS_ENABLED) if (get.success) expect(get.data).toBe(true) expect(keyBindingService.loadFromConfig).toHaveBeenCalled() expect(keyBindingService.start).toHaveBeenCalled() }) it('캡션 초기 상태를 읽는다', async () => { registerCaptionHandlers() const res = await invokeIpc(IPC_CHANNELS.CAPTION.GET_STATE) expect(res.success).toBe(true) if (res.success) expect(res.data).toBe('inactive') }) it('캡션 설정을 바꾼다', async () => { registerCaptionHandlers() const res = await invokeIpc(IPC_CHANNELS.CAPTION.SET_CONFIG, { fontSize: 20, opacity: 0.5 }) expect(res.success).toBe(true) }) it('캡션 시작 실패는 success:false 로 나온다', async () => { registerCaptionHandlers() const res = await invokeIpc(IPC_CHANNELS.CAPTION.START) if (res.success) { const stop = await invokeIpc(IPC_CHANNELS.CAPTION.STOP) expect(stop.success).toBe(true) } else { expect(res.error.code).toBeTruthy() } }) it('RAG 문서 목록 IPC', async () => { registerRAGHandlers() const res = await invokeIpc(IPC_CHANNELS.RAG.GET_DOCUMENTS) expect(res.success).toBe(true) if (res.success) expect(Array.isArray(res.data)).toBe(true) }) it('RAG 질의 실패는 에러다', async () => { registerRAGHandlers() const res = await invokeIpc(IPC_CHANNELS.RAG.QUERY, { query: 'hello', topK: 3 }) expect(res.success).toBe(false) }) it('대화 상태를 읽는다', async () => { registerVoiceConversationHandlers() const res = await invokeIpc(IPC_CHANNELS.VOICE_CONVERSATION.GET_STATE) expect(res.success).toBe(true) }) it('대화 히스토리를 읽는다', async () => { registerVoiceConversationHandlers() const res = await invokeIpc(IPC_CHANNELS.VOICE_CONVERSATION.GET_HISTORY) expect(res.success).toBe(true) if (res.success) expect(Array.isArray(res.data)).toBe(true) }) it('세션 없이 대화 메시지는 실패한다', async () => { registerVoiceConversationHandlers() const res = await invokeIpc(IPC_CHANNELS.VOICE_CONVERSATION.SEND_MESSAGE, { text: 'hi' }) expect(res.success).toBe(false) }) it('보이스 액션 프리셋을 읽는다', async () => { registerVoiceActionHandlers() const res = await invokeIpc(IPC_CHANNELS.VOICE_ACTION.GET_PRESETS) expect(res.success).toBe(true) if (res.success) expect((res.data as unknown[]).length).toBeGreaterThan(0) }) it('보이스 액션 활성 토글', async () => { registerVoiceActionHandlers() await invokeIpc(IPC_CHANNELS.VOICE_ACTION.SET_ENABLED, { enabled: true }) const res = await invokeIpc(IPC_CHANNELS.VOICE_ACTION.IS_ENABLED) expect(res.success).toBe(true) if (res.success) expect(res.data).toBe(true) }) it('파일 전사 상태를 읽는다', async () => { registerFileTranscriptionHandlers() const res = await invokeIpc(IPC_CHANNELS.FILE_TRANSCRIPTION.GET_STATE) expect(res.success).toBe(true) if (res.success) expect(res.data.state).toBe('idle') }) it('파일 전사 잘못된 경로는 실패한다', async () => { registerFileTranscriptionHandlers() const res = await invokeIpc(IPC_CHANNELS.FILE_TRANSCRIPTION.START, { filePath: 'nope.txt' }) expect(res.success).toBe(false) }) it('회의 상태를 읽는다', async () => { registerMeetingModeHandlers() const res = await invokeIpc(IPC_CHANNELS.MEETING_MODE.GET_STATE) expect(res.success).toBe(true) }) it('회의 세션 목록을 읽는다', async () => { registerMeetingModeHandlers() const res = await invokeIpc(IPC_CHANNELS.MEETING_MODE.GET_SESSIONS, { page: 1, pageSize: 10 }) expect(res.success).toBe(true) }) it('없는 회의 세션 조회는 실패한다', async () => { registerMeetingModeHandlers() const res = await invokeIpc(IPC_CHANNELS.MEETING_MODE.GET_SESSION, { sessionId: 'missing' }) expect(res.success).toBe(false) if (!res.success) expect(res.error.code).toBe(ErrorCode.MeetingSessionNotFound) }) it('딕테이션 템플릿 목록 IPC', async () => { registerTemplateHandlers() const res = await invokeIpc(IPC_CHANNELS.DICTATION_TEMPLATE.GET_ALL) expect(res.success).toBe(true) if (res.success) expect((res.data as unknown[]).length).toBeGreaterThan(0) }) it('없는 딕테이션 템플릿 세션 시작은 실패한다', async () => { registerTemplateHandlers() const res = await invokeIpc(IPC_CHANNELS.DICTATION_TEMPLATE.START_SESSION, { templateId: 'missing' }) expect(res.success).toBe(false) }) it('회의 문서 템플릿 목록 IPC', async () => { registerMeetingDocTemplateHandlers() const res = await invokeIpc(IPC_CHANNELS.MEETING_DOC_TEMPLATE.GET_ALL) expect(res.success).toBe(true) if (res.success) expect((res.data as unknown[]).length).toBeGreaterThan(0) }) it('빌트인 회의 템플릿 삭제는 실패한다', async () => { registerMeetingDocTemplateHandlers() const res = await invokeIpc(IPC_CHANNELS.MEETING_DOC_TEMPLATE.DELETE, { id: 'builtin-minutes' }) expect(res.success).toBe(false) if (!res.success) expect(res.error.code).toBe(ErrorCode.MeetingDocTemplateBuiltinDelete) }) })