d3ro-voice/packages/core/__tests__/keybinding-redteam-r2-16.test.ts

185 lines
8.3 KiB
TypeScript

// packages/core/__tests__/keybinding-redteam-r2-16.test.ts
// 레드팀 r2-16 회귀 테스트.
// - 저장 코덱(decodeKeyBindingMap): 사용자가 비운 액션([])을 기본값으로 되살리지 않는다.
// - 런타임(ChordStateMachine): 마우스 버튼 누름 중 수정자를 더 눌러도 mouseup 이 release 한다.
import { describe, it, expect } from 'vitest'
import { ChordStateMachine, isChordComponentOf } from '../src/keybinding-runtime'
import type { ChordClock, ChordTriggerEvent } from '../src/keybinding-runtime'
import {
KEYBINDING_ACTIONS,
MouseButton,
VK,
bindingKey,
createDefaultBindingMap,
decodeKeyBindingMap,
kb
} from '../src/keybinding'
import type { KeyBinding } from '../src/keybinding'
const F8 = 0x77
function key(code: number, mods: Partial<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>> = {}): KeyBinding {
return kb(code, mods)
}
function mouse(code: number, mods: Partial<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>> = {}): KeyBinding {
return { ...kb(code, mods), device: 'mouse' }
}
// ============================================================
// decodeKeyBindingMap
// ============================================================
describe('decodeKeyBindingMap', () => {
it('명시적 빈 목록은 빈 목록 그대로 보존한다 (기본값으로 되살리지 않는다)', () => {
const decoded = decodeKeyBindingMap({ ...createDefaultBindingMap(), 'history-popup': [] })
expect(decoded.map['history-popup']).toEqual([])
expect(decoded.repairedActions).toEqual([])
expect(decoded.needsRewrite).toBe(false)
})
it('빈 목록은 decode → 저장 → decode 왕복에서도 빈 목록이다', () => {
const first = decodeKeyBindingMap({ ...createDefaultBindingMap(), caption: [] })
const roundTrip = decodeKeyBindingMap(JSON.parse(JSON.stringify(first.map)))
expect(roundTrip.map.caption).toEqual([])
expect(roundTrip.map).toEqual(first.map)
})
it.each([
['누락', undefined],
['배열 아님', 'garbage'],
['객체', { device: 'keyboard', code: 65 }],
['비어 있지 않지만 전부 손상', [{ device: 'keyboard', code: 65 }, null, 7]]
])('%s 인 액션만 기본값으로 복구하고 repairedActions 에 넣는다', (_label, stored) => {
const defaults = createDefaultBindingMap()
const decoded = decodeKeyBindingMap({ ...defaults, command: stored })
expect(decoded.map.command).toEqual(defaults.command)
expect(decoded.repairedActions).toEqual(['command'])
expect(decoded.needsRewrite).toBe(true)
})
it('레코드가 아니면 모든 액션을 복구한다', () => {
for (const value of [null, undefined, 'x', 7, [key(F8)]]) {
const decoded = decodeKeyBindingMap(value)
expect(decoded.map).toEqual(createDefaultBindingMap())
expect(decoded.repairedActions.sort()).toEqual(KEYBINDING_ACTIONS.map((a) => a.id).sort())
expect(decoded.needsRewrite).toBe(true)
}
})
it('일부 손상 항목은 버리고 유효 항목을 유지한다 — 복구가 아니라 정규화다', () => {
const decoded = decodeKeyBindingMap({ ...createDefaultBindingMap(), dictation: [key(F8), 'junk'] })
expect(decoded.map.dictation).toEqual([key(F8)])
expect(decoded.repairedActions).toEqual([])
expect(decoded.needsRewrite).toBe(true)
})
it('정규화·중복 제거가 필요하면 needsRewrite, 이미 정본 모양이면 다시 쓰지 않는다', () => {
const unnormalized = decodeKeyBindingMap({
...createDefaultBindingMap(),
dictation: [key(VK.AltRight, { alt: true, ctrl: true })]
})
expect(unnormalized.map.dictation).toEqual([key(VK.AltRight, { ctrl: true })])
expect(unnormalized.needsRewrite).toBe(true)
const duplicated = decodeKeyBindingMap({ ...createDefaultBindingMap(), dictation: [key(F8), key(F8)] })
expect(duplicated.map.dictation).toEqual([key(F8)])
expect(duplicated.needsRewrite).toBe(true)
expect(decodeKeyBindingMap(createDefaultBindingMap()).needsRewrite).toBe(false)
})
it('알 수 없는 액션 키는 버리고 모든 액션 키를 갖는다', () => {
const decoded = decodeKeyBindingMap({ ...createDefaultBindingMap(), 'not-an-action': [key(F8)] })
expect(Object.keys(decoded.map).sort()).toEqual(KEYBINDING_ACTIONS.map((a) => a.id).sort())
expect(Object.keys(decoded.map)).not.toContain('not-an-action')
})
it('반환 맵은 기본값 정의와 참조를 공유하지 않는다', () => {
const decoded = decodeKeyBindingMap(null)
decoded.map.dictation[0]!.ctrl = true
expect(createDefaultBindingMap().dictation[0]!.ctrl).toBe(false)
})
})
// ============================================================
// 마우스 hold 바인딩 release
// ============================================================
const clock: ChordClock = {
now: () => 1_000,
schedule: () => () => undefined
}
function labels(events: ChordTriggerEvent[]): string[] {
return events.map((e) => `${e.actionId}:${e.type}`)
}
describe('ChordStateMachine — 마우스 버튼 누름 중 수정자 추가', () => {
it('X1 누름 → Alt 누름 → X1 놓음(mouseup 은 X1+Alt) 이면 release 한다', () => {
const events: ChordTriggerEvent[] = []
const machine = new ChordStateMachine({ clock, onTrigger: (e) => events.push(e) })
const x1 = mouse(MouseButton.Back)
machine.setBindings([{ actionId: 'dictation', holdMode: true, doublePress: false, binding: x1 }])
machine.keyDown(bindingKey(x1))
// Alt 키 누름/놓음 자체는 X1 바인딩의 구성 요소가 아니다.
machine.keyUp(bindingKey(key(VK.AltLeft)), VK.AltLeft)
expect(labels(events)).toEqual(['dictation:pressed'])
machine.keyUp(bindingKey(mouse(MouseButton.Back, { alt: true })), {
device: 'mouse',
code: MouseButton.Back
})
expect(labels(events)).toEqual(['dictation:pressed', 'dictation:released'])
// 눌림 상태가 남지 않았으므로 다음 누름이 auto-repeat 로 삼켜지지 않는다.
machine.keyDown(bindingKey(x1))
expect(labels(events)).toEqual(['dictation:pressed', 'dictation:released', 'dictation:pressed'])
})
it('다른 버튼의 mouseup 은 release 하지 않는다', () => {
const events: ChordTriggerEvent[] = []
const machine = new ChordStateMachine({ clock, onTrigger: (e) => events.push(e) })
const x1 = mouse(MouseButton.Back)
machine.setBindings([{ actionId: 'dictation', holdMode: true, doublePress: false, binding: x1 }])
machine.keyDown(bindingKey(x1))
machine.keyUp(bindingKey(mouse(MouseButton.Forward, { alt: true })), {
device: 'mouse',
code: MouseButton.Forward
})
expect(labels(events)).toEqual(['dictation:pressed'])
})
it('숫자 component 는 키보드 VK 로 해석한다 (옛 호출 형태 호환)', () => {
const events: ChordTriggerEvent[] = []
const machine = new ChordStateMachine({ clock, onTrigger: (e) => events.push(e) })
const ctrlShiftV = key(0x56, { ctrl: true, shift: true })
machine.setBindings([{ actionId: 'history-popup', holdMode: false, doublePress: false, binding: ctrlShiftV }])
machine.keyDown(bindingKey(ctrlShiftV))
machine.keyUp(bindingKey(key(VK.ShiftLeft, { ctrl: true })), VK.ShiftLeft)
expect(labels(events)).toEqual(['history-popup:pressed', 'history-popup:released'])
})
})
describe('isChordComponentOf', () => {
it('같은 장치·같은 코드면 수정자와 무관하게 구성 요소다', () => {
expect(isChordComponentOf({ device: 'mouse', code: MouseButton.Back }, mouse(MouseButton.Back))).toBe(true)
expect(
isChordComponentOf({ device: 'mouse', code: MouseButton.Back }, mouse(MouseButton.Back, { ctrl: true }))
).toBe(true)
})
it('마우스 버튼 코드는 같은 숫자의 키보드 키와 섞이지 않는다', () => {
// MouseButton.Back 과 같은 숫자의 VK 키보드 바인딩
expect(isChordComponentOf({ device: 'mouse', code: MouseButton.Back }, key(MouseButton.Back))).toBe(false)
expect(isChordComponentOf({ device: 'keyboard', code: MouseButton.Back }, mouse(MouseButton.Back))).toBe(false)
})
it('키보드 수정자 키는 그 수정자를 켠 바인딩(마우스 포함)의 구성 요소다', () => {
expect(isChordComponentOf({ device: 'keyboard', code: VK.CtrlLeft }, mouse(MouseButton.Back, { ctrl: true }))).toBe(true)
expect(isChordComponentOf({ device: 'keyboard', code: VK.AltLeft }, mouse(MouseButton.Back))).toBe(false)
})
})