d3ro-voice/apps/desktop/tests/main/services/keybinding-redteam-r2-16.test.ts

338 lines
13 KiB
TypeScript

// 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<IPCResult<unknown>>
function captureHandlers(): Map<string, Handler> {
const handlers = new Map<string, Handler>()
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Handler) => {
handlers.set(channel, handler)
})
registerKeyBindingHandlers()
return handlers
}
async function invoke(handlers: Map<string, Handler>, channel: string, params?: unknown): Promise<IPCResult<unknown>> {
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<string, unknown> = {
suggestionTuningRevision: 6,
keyBindings: { ...createDefaultBindingMap(), 'history-popup': [] }
}
const setCalls: string[] = []
class TestStore<T extends Record<string, unknown>> {
store: T
constructor(options: { defaults: T }) {
this.store = { ...options.defaults, ...persisted } as T
}
get<K extends keyof T>(key: K): T[K] {
return this.store[key]
}
set<K extends keyof T>(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<KeyBindingMap> = { ...createDefaultBindingMap(), 'history-popup': [] }
delete stored['suggestion-dismiss']
const persisted: Record<string, unknown> = { suggestionTuningRevision: 6, keyBindings: stored }
class TestStore<T extends Record<string, unknown>> {
store: T
constructor(options: { defaults: T }) {
this.store = { ...options.defaults, ...persisted } as T
}
get<K extends keyof T>(key: K): T[K] {
return this.store[key]
}
set<K extends keyof T>(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()
})
})