// tests/main/services/keybinding-redteam-r2-16.test.ts // 레드팀 r2-16 회귀 테스트 (데스크톱 어댑터). // 1) globalShortcut 은 macOS 에서만, 켜진 기능의 바인딩만 예약한다 — Windows 에서 키를 빼앗지 않는다. // 2) 마우스 hold 바인딩은 누름 중 수정자를 더 눌러도 mouseup 에서 release 된다. // 3) 사용자가 비운 액션([])은 다른 액션 저장 · 재시작 뒤에도 비어 있다. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { globalShortcut, ipcMain } from 'electron' import { uIOhook } from 'uiohook-napi' import type { UiohookMouseEvent } from 'uiohook-napi' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { MouseButton, createDefaultBindingMap } from '@d3ro/core/keybinding' import type { KeyBinding, KeyBindingMap } from '@d3ro/core/types' import type { IPCResult } from '@d3ro/core/errors' import { acceleratorsToReserve, bindingToAccelerator, isKeyBindingActionFeatureEnabled } from '../../../src/main/services/keybinding-accelerator-policy' import type { ReservableBinding } from '../../../src/main/services/keybinding-accelerator-policy' import { configGet, configSet, initInMemoryConfig, loadKeyBindings, resetInMemoryConfig } from '../../../src/main/services/ConfigService' import { getKeyBindingService } from '../../../src/main/services/KeyBindingService' import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService' import { registerKeyBindingHandlers } from '../../../src/main/ipc/keybinding-handlers' const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { value: platform, configurable: true }) } function restorePlatform(): void { if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform) } function registeredAccelerators(): string[] { return vi.mocked(globalShortcut.register).mock.calls.map((call) => String(call[0])) } function defaultReservable(): ReservableBinding[] { const map = createDefaultBindingMap() return (Object.keys(map) as (keyof KeyBindingMap)[]).flatMap((actionId) => map[actionId].map((binding) => ({ actionId, binding })) ) } // ============================================================ // 1) accelerator 예약 정책 (순수) // ============================================================ describe('acceleratorsToReserve', () => { it('Windows 에서는 기본 바인딩을 하나도 예약하지 않는다', () => { expect( acceleratorsToReserve(defaultReservable(), { platform: 'win32', flags: { suggestionEnabled: true } }) ).toEqual([]) expect( acceleratorsToReserve(defaultReservable(), { platform: 'linux', flags: { suggestionEnabled: true } }) ).toEqual([]) }) it('macOS 에서 제안 기능이 꺼져 있으면 suggestion-* 바인딩은 예약하지 않는다', () => { expect( acceleratorsToReserve(defaultReservable(), { platform: 'darwin', flags: { suggestionEnabled: false } }) ).toEqual(['Control+Shift+V', 'Control+Shift+C']) }) it('macOS 에서 제안 기능이 켜져 있으면 suggestion-* 바인딩도 예약한다', () => { expect( acceleratorsToReserve(defaultReservable(), { platform: 'darwin', flags: { suggestionEnabled: true } }) ).toEqual([ 'Control+Shift+V', 'Control+Shift+C', 'Control+Alt+Return', 'Control+Alt+Down', 'Control+Alt+Up', 'Control+Alt+Backspace' ]) }) it('한 바인딩을 공유하는 액션 중 하나라도 켜져 있으면 예약하고, 중복은 한 번만 낸다', () => { const shared: KeyBinding = { device: 'keyboard', code: 0x78, ctrl: true, alt: false, shift: true, meta: false } expect( acceleratorsToReserve( [ { actionId: 'suggestion-next', binding: shared }, { actionId: 'command-popup', binding: shared } ], { platform: 'darwin', flags: { suggestionEnabled: false } } ) ).toEqual(['Control+Shift+F9']) }) it('마우스 · 수정자 단독 바인딩은 accelerator 로 표현하지 않는다', () => { const x1: KeyBinding = { device: 'mouse', code: MouseButton.Back, ctrl: false, alt: false, shift: false, meta: false } expect(bindingToAccelerator(x1, 'darwin')).toBeNull() expect(bindingToAccelerator(createDefaultBindingMap().dictation[0]!, 'darwin')).toBeNull() }) it('meta 는 macOS 에서 Cmd, 그 외에서는 Super 다', () => { const b: KeyBinding = { device: 'keyboard', code: 0x4b, ctrl: false, alt: false, shift: false, meta: true } expect(bindingToAccelerator(b, 'darwin')).toBe('Cmd+K') expect(bindingToAccelerator(b, 'win32')).toBe('Super+K') }) it('기능 스위치가 없는 액션은 항상 켜져 있다', () => { expect(isKeyBindingActionFeatureEnabled('history-popup', { suggestionEnabled: false })).toBe(true) expect(isKeyBindingActionFeatureEnabled('suggestion-accept', { suggestionEnabled: false })).toBe(false) }) }) // ============================================================ // 1) + 2) KeyBindingService 어댑터 // ============================================================ describe('KeyBindingService — globalShortcut 예약 · 마우스 release', () => { beforeEach(() => { initInMemoryConfig() vi.mocked(globalShortcut.register).mockClear() vi.mocked(globalShortcut.unregister).mockClear() vi.mocked(uIOhook.on).mockClear() }) afterEach(() => { getKeyBindingService().stop() restorePlatform() resetInMemoryConfig() }) it('Windows 에서는 기본 설정으로 시작해도 globalShortcut 을 하나도 등록하지 않는다', () => { setPlatform('win32') const service = getKeyBindingService() service.loadFromConfig() service.start() expect(service.registeredBindingCount).toBeGreaterThan(0) expect(globalShortcut.register).not.toHaveBeenCalled() configSet('suggestionEnabled', true) expect(globalShortcut.register).not.toHaveBeenCalled() }) it('macOS 에서는 켜진 기능만 예약하고, 제안 기능을 켜고 끄면 다시 맞춘다', () => { setPlatform('darwin') const service = getKeyBindingService() service.loadFromConfig() service.start() expect(registeredAccelerators()).toEqual(['Control+Shift+V', 'Control+Shift+C']) vi.mocked(globalShortcut.register).mockClear() configSet('suggestionEnabled', true) expect(registeredAccelerators()).toContain('Control+Alt+Down') vi.mocked(globalShortcut.register).mockClear() configSet('suggestionEnabled', false) expect(registeredAccelerators()).toEqual(['Control+Shift+V', 'Control+Shift+C']) }) it('X1 hold 중 Alt 를 더 누른 채 버튼을 놓아도 dictation 이 released 된다', () => { setPlatform('win32') const map = createDefaultBindingMap() map.dictation = [{ device: 'mouse', code: MouseButton.Back, ctrl: false, alt: false, shift: false, meta: false }] configSet('keyBindings', map) const service = getKeyBindingService() service.loadFromConfig() service.start() const handlerOf = (event: string): ((e: UiohookMouseEvent) => void) => { const call = vi.mocked(uIOhook.on).mock.calls.find((c) => c[0] === event) if (!call) throw new Error(`no ${event} handler`) return call[1] as (e: UiohookMouseEvent) => void } const events: string[] = [] const listener = (p: KeyBindingTriggerPayload): void => { events.push(`${p.actionId}:${p.type}`) } service.on('triggered', listener) const mouseEvent = (alt: boolean): UiohookMouseEvent => ({ button: MouseButton.Back, altKey: alt, ctrlKey: false, shiftKey: false, metaKey: false }) as unknown as UiohookMouseEvent handlerOf('mousedown')(mouseEvent(false)) handlerOf('mouseup')(mouseEvent(true)) service.off('triggered', listener) expect(events).toEqual(['dictation:pressed', 'dictation:released']) }) }) // ============================================================ // 3) 비운 액션의 영속 // ============================================================ type Handler = (event: unknown, ...args: unknown[]) => Promise> function captureHandlers(): Map { const handlers = new Map() vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Handler) => { handlers.set(channel, handler) }) registerKeyBindingHandlers() return handlers } async function invoke(handlers: Map, channel: string, params?: unknown): Promise> { const handler = handlers.get(channel) if (!handler) throw new Error(`IPC handler not registered: ${channel}`) return handler({}, params) } const FREE_BINDING: KeyBinding = { device: 'keyboard', code: 0x78, ctrl: true, alt: false, shift: true, meta: false } describe('keybinding IPC — 비운 액션 보존', () => { beforeEach(() => { initInMemoryConfig() }) afterEach(() => { getKeyBindingService().stop() resetInMemoryConfig() }) it('history-popup 을 비운 뒤 다른 액션을 저장해도 history-popup 은 비어 있다 (GET_MAP 포함)', async () => { const handlers = captureHandlers() const cleared = await invoke(handlers, IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'history-popup', bindings: [] }) expect(cleared.success).toBe(true) const other = await invoke(handlers, IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'dictation', bindings: [FREE_BINDING] }) expect(other.success).toBe(true) expect(configGet('keyBindings')['history-popup']).toEqual([]) const map = await invoke(handlers, IPC_CHANNELS.KEYBINDING.GET_MAP) expect(map.success && (map.data as KeyBindingMap)['history-popup']).toEqual([]) expect(loadKeyBindings()['history-popup']).toEqual([]) }) it('비운 액션은 런타임에도 등록되지 않는다', async () => { const handlers = captureHandlers() const before = (() => { getKeyBindingService().loadFromConfig() return getKeyBindingService().registeredBindingCount })() await invoke(handlers, IPC_CHANNELS.KEYBINDING.SET_BINDINGS, { actionId: 'history-popup', bindings: [] }) expect(getKeyBindingService().registeredBindingCount).toBe(before - 1) }) }) describe('ConfigService 재시작 — 비운 액션 보존', () => { afterEach(() => { vi.doUnmock('electron-store') vi.resetModules() }) it('저장소에 []로 남은 액션은 initConfigService 마이그레이션 뒤에도 [] 다', async () => { const persisted: Record = { suggestionTuningRevision: 6, keyBindings: { ...createDefaultBindingMap(), 'history-popup': [] } } const setCalls: string[] = [] class TestStore> { store: T constructor(options: { defaults: T }) { this.store = { ...options.defaults, ...persisted } as T } get(key: K): T[K] { return this.store[key] } set(key: K, value: T[K]): void { setCalls.push(String(key)) this.store[key] = value } delete(key: string): void { delete this.store[key as keyof T] } } vi.doMock('electron-store', () => ({ default: TestStore })) const fresh = await import('../../../src/main/services/ConfigService') await fresh.initConfigService() expect(fresh.configGet('keyBindings')['history-popup']).toEqual([]) expect(fresh.loadKeyBindings()['history-popup']).toEqual([]) // 이미 정본 모양이면 다시 쓰지 않는다. expect(setCalls).not.toContain('keyBindings') fresh.resetInMemoryConfig() }) it('새 액션이 빠진 저장값은 그 액션만 기본값으로 채워 다시 쓴다', async () => { const stored: Partial = { ...createDefaultBindingMap(), 'history-popup': [] } delete stored['suggestion-dismiss'] const persisted: Record = { suggestionTuningRevision: 6, keyBindings: stored } class TestStore> { store: T constructor(options: { defaults: T }) { this.store = { ...options.defaults, ...persisted } as T } get(key: K): T[K] { return this.store[key] } set(key: K, value: T[K]): void { this.store[key] = value } delete(key: string): void { delete this.store[key as keyof T] } } vi.doMock('electron-store', () => ({ default: TestStore })) const fresh = await import('../../../src/main/services/ConfigService') await fresh.initConfigService() const bindings = fresh.configGet('keyBindings') expect(bindings['suggestion-dismiss']).toEqual(createDefaultBindingMap()['suggestion-dismiss']) expect(bindings['history-popup']).toEqual([]) fresh.resetInMemoryConfig() }) })