From c5b3bdedb6e790a293cd67de58e06a4fee54e0f1 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:53:43 +0900 Subject: [PATCH] fix(voice): let a click on the RecordingTip actually cancel the recording --- apps/desktop/src/main/ipc/voice-handlers.ts | 14 +++- .../ipc/voice-handlers-redteam-r1-4.test.ts | 81 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/tests/main/ipc/voice-handlers-redteam-r1-4.test.ts diff --git a/apps/desktop/src/main/ipc/voice-handlers.ts b/apps/desktop/src/main/ipc/voice-handlers.ts index 2e7c87c..30ad9ba 100644 --- a/apps/desktop/src/main/ipc/voice-handlers.ts +++ b/apps/desktop/src/main/ipc/voice-handlers.ts @@ -14,6 +14,11 @@ function safeSendToRenderer(channel: string, data: unknown): void { } } +/** 두 IPC 전송 경로(invoke/send)가 공유하는 단일 취소 동작 */ +function cancelVoiceSession(): void { + getVoiceModeService().cancelSession() +} + export function registerVoiceHandlers(): void { // 음성 세션 에러/경고를 메인 윈도우로 브로드캐스트 — // 모델 미설치/엔진 실패 등의 경고가 recording tip(3초) 외에도 @@ -55,11 +60,18 @@ export function registerVoiceHandlers(): void { } }) + // 취소는 두 경로로 들어온다 — 메인 윈도우는 invoke(handle), RecordingTip 팝업은 + // popupAPI.send(= ipcRenderer.send, 단방향)라서 ipcMain.on이 따로 있어야 한다. + // handle()은 send() 메시지를 절대 받지 않는다. ipcMain.handle(IPC_CHANNELS.VOICE.CANCEL_RECORDING, async (_event, _params: CancelRecordingParams) => { - getVoiceModeService().cancelSession() + cancelVoiceSession() return ipcSuccess(undefined) }) + ipcMain.on(IPC_CHANNELS.VOICE.CANCEL_RECORDING, () => { + cancelVoiceSession() + }) + ipcMain.handle(IPC_CHANNELS.VOICE.GET_STATE, async () => { return ipcSuccess(getVoiceModeService().getState()) }) diff --git a/apps/desktop/tests/main/ipc/voice-handlers-redteam-r1-4.test.ts b/apps/desktop/tests/main/ipc/voice-handlers-redteam-r1-4.test.ts new file mode 100644 index 0000000..1790055 --- /dev/null +++ b/apps/desktop/tests/main/ipc/voice-handlers-redteam-r1-4.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' + +// ipcMain.handle(invoke 전용)과 ipcMain.on(send 전용)을 분리해 기록한다. +// Electron에서 ipcRenderer.send 메시지는 handle 등록에 절대 도달하지 않으므로 +// 이 둘을 섞어 쓰는 하네스로는 RecordingTip 클릭 취소 회귀를 잡을 수 없다. +const invokeHandlers = vi.hoisted( + () => new Map Promise>() +) +const sendListeners = vi.hoisted( + () => new Map void>>() +) +const voice = vi.hoisted(() => ({ + cancelSession: vi.fn(), + on: vi.fn(), + startSession: vi.fn(async () => undefined), + stopSession: vi.fn(async () => undefined), + getState: vi.fn(() => ({})), + currentSession: null +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (event: unknown, ...args: unknown[]) => Promise) => { + invokeHandlers.set(channel, handler) + }), + on: vi.fn((channel: string, listener: (event: unknown, ...args: unknown[]) => void) => { + const list = sendListeners.get(channel) ?? [] + list.push(listener) + sendListeners.set(channel, list) + }) + } +})) + +vi.mock('../../../src/main/services/VoiceModeService', () => ({ + getVoiceModeService: () => voice +})) +vi.mock('../../../src/main/windows/WindowManager', () => ({ + getMainWindow: () => null +})) + +/** ipcRenderer.send 의미론 — ipcMain.on 리스너에만 전달된다 */ +function simulateRendererSend(channel: string, ...args: unknown[]): number { + const listeners = sendListeners.get(channel) ?? [] + for (const listener of listeners) listener({}, ...args) + return listeners.length +} + +beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + invokeHandlers.clear() + sendListeners.clear() + const mod = await import('../../../src/main/ipc/voice-handlers') + mod.registerVoiceHandlers() +}) + +describe('VOICE.CANCEL_RECORDING — RecordingTip 팝업(send) 경로', () => { + it('popupAPI.send로 보낸 취소가 세션을 취소한다', () => { + const delivered = simulateRendererSend(IPC_CHANNELS.VOICE.CANCEL_RECORDING, {}) + + expect(delivered).toBe(1) + expect(voice.cancelSession).toHaveBeenCalledTimes(1) + }) + + it('RecordingTip 스크립트가 보내는 채널 문자열과 IPC 상수가 일치한다', () => { + expect(IPC_CHANNELS.VOICE.CANCEL_RECORDING).toBe('voice:cancelRecording') + }) +}) + +describe('VOICE.CANCEL_RECORDING — 메인 윈도우(invoke) 경로', () => { + it('invoke 경로도 여전히 세션을 취소하고 성공을 반환한다', async () => { + const handler = invokeHandlers.get(IPC_CHANNELS.VOICE.CANCEL_RECORDING) + if (!handler) throw new Error('cancel invoke handler was not registered') + + const result = await handler({}, {}) + + expect(voice.cancelSession).toHaveBeenCalledTimes(1) + expect(result).toMatchObject({ success: true }) + }) +})