d3ro-voice/packages/core/__tests__/keybinding-runtime.test.ts

479 lines
17 KiB
TypeScript

// packages/core/__tests__/keybinding-runtime.test.ts
// 키바인딩 런타임 상태 머신(src/keybinding-runtime.ts) 계약 검증.
// press / release · auto-repeat · 더블프레스 · 구성 키 release · AltGr 보류를
// 가짜 시계로 결정적으로 돌린다 (uiohook · electron 없이).
import { describe, it, expect, beforeEach } from 'vitest'
import {
ALTGR_CHORD_GRACE_MS,
ChordStateMachine,
isAltGrShapedBinding,
modifierFlagOfKeyCode
} from '../src/keybinding-runtime'
import type {
ChordBindingEntry,
ChordClock,
ChordTriggerEvent
} from '../src/keybinding-runtime'
import {
KEYBINDING_ACTIONS,
MouseButton,
VK,
bindingKey,
kb,
normalizeBinding
} from '../src/keybinding'
import type { KeyBinding, KeyBindingActionId } from '../src/keybinding'
// ============================================================
// 가짜 시계
// ============================================================
interface FakeTimer {
at: number
callback: () => void
active: boolean
}
class FakeClock implements ChordClock {
private _now = 1_000
private readonly _timers: FakeTimer[] = []
now(): number {
return this._now
}
schedule(callback: () => void, delayMs: number): () => void {
const timer: FakeTimer = { at: this._now + delayMs, callback, active: true }
this._timers.push(timer)
return () => {
timer.active = false
}
}
get activeTimerCount(): number {
return this._timers.filter((t) => t.active).length
}
advance(ms: number): void {
const target = this._now + ms
for (;;) {
const due = this._timers
.filter((t) => t.active && t.at <= target)
.sort((a, b) => a.at - b.at)[0]
if (due === undefined) break
due.active = false
this._now = due.at
due.callback()
}
this._now = target
}
}
// ============================================================
// 하네스
// ============================================================
function entry(
actionId: KeyBindingActionId,
binding: KeyBinding,
opts: { holdMode?: boolean; doublePress?: boolean } = {}
): ChordBindingEntry {
return {
actionId,
binding,
holdMode: opts.holdMode ?? false,
doublePress: opts.doublePress ?? false
}
}
function defaultEntries(): ChordBindingEntry[] {
return KEYBINDING_ACTIONS.flatMap((spec) =>
spec.defaultBindings.map((binding) => ({
actionId: spec.id,
holdMode: spec.holdMode,
doublePress: spec.doublePress,
binding
}))
)
}
/** uiohook 어댑터가 만드는 것과 같은 이벤트 키 (이벤트 수정자 플래그 그대로) */
function keyOf(
code: number,
mods: Partial<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>> = {}
): string {
return bindingKey(kb(code, mods))
}
function label(e: ChordTriggerEvent): string {
return `${e.actionId}:${e.type}`
}
let clock: FakeClock
let events: ChordTriggerEvent[]
let machine: ChordStateMachine
beforeEach(() => {
clock = new FakeClock()
events = []
machine = new ChordStateMachine({ clock, onTrigger: (e) => events.push(e) })
})
// ============================================================
// 순수 판정
// ============================================================
describe('modifierFlagOfKeyCode', () => {
it('maps modifier VKs to their flag and others to null', () => {
expect(modifierFlagOfKeyCode(VK.AltRight)).toBe('alt')
expect(modifierFlagOfKeyCode(VK.AltLeft)).toBe('alt')
expect(modifierFlagOfKeyCode(VK.CtrlLeft)).toBe('ctrl')
expect(modifierFlagOfKeyCode(VK.ShiftRight)).toBe('shift')
expect(modifierFlagOfKeyCode(VK.MetaLeft)).toBe('meta')
expect(modifierFlagOfKeyCode(VK.ArrowDown)).toBeNull()
expect(modifierFlagOfKeyCode(0x41)).toBeNull()
})
})
describe('isAltGrShapedBinding', () => {
it('recognises the default Ctrl+RightAlt binding after normalization', () => {
const command = normalizeBinding(kb(VK.AltRight, { ctrl: true }))
expect(command.alt).toBe(false) // 정규화가 자기 플래그를 끈다 — 과거 가드가 죽은 이유
expect(isAltGrShapedBinding(command)).toBe(true)
})
it('recognises Ctrl+Alt with a modifier main key in every spelling', () => {
expect(isAltGrShapedBinding(normalizeBinding(kb(VK.CtrlLeft, { alt: true })))).toBe(true)
expect(isAltGrShapedBinding(normalizeBinding(kb(VK.CtrlRight, { alt: true })))).toBe(true)
expect(
isAltGrShapedBinding(normalizeBinding(kb(VK.ShiftRight, { ctrl: true, alt: true })))
).toBe(true)
expect(
isAltGrShapedBinding(normalizeBinding(kb(VK.AltRight, { ctrl: true, shift: true })))
).toBe(true)
})
it('rejects bindings that are not modifier-only Ctrl+Alt chords', () => {
expect(isAltGrShapedBinding(normalizeBinding(kb(VK.AltRight)))).toBe(false)
expect(isAltGrShapedBinding(normalizeBinding(kb(VK.AltRight, { shift: true })))).toBe(false)
expect(isAltGrShapedBinding(kb(VK.ArrowDown, { ctrl: true, alt: true }))).toBe(false)
expect(
isAltGrShapedBinding({ ...kb(MouseButton.Back, { ctrl: true, alt: true }), device: 'mouse' })
).toBe(false)
})
})
// ============================================================
// press / release
// ============================================================
describe('ChordStateMachine — press / release', () => {
it('emits pressed then released with the hold duration', () => {
machine.setBindings([entry('history-popup', kb(0x56, { ctrl: true, shift: true }))])
const key = keyOf(0x56, { ctrl: true, shift: true })
machine.keyDown(key)
clock.advance(450)
machine.keyUp(key, 0x56)
expect(events.map(label)).toEqual(['history-popup:pressed', 'history-popup:released'])
expect(events[0]?.durationMs).toBe(0)
expect(events[1]?.durationMs).toBe(450)
expect(events[1]?.binding).toEqual(normalizeBinding(kb(0x56, { ctrl: true, shift: true })))
})
it('ignores auto-repeat keydowns while the key is held', () => {
machine.setBindings([entry('history-popup', kb(0x56, { ctrl: true, shift: true }))])
const key = keyOf(0x56, { ctrl: true, shift: true })
machine.keyDown(key)
machine.keyDown(key)
machine.keyDown(key)
machine.keyUp(key, 0x56)
expect(events.map(label)).toEqual(['history-popup:pressed', 'history-popup:released'])
})
it('ignores keys that match no binding and releases nothing that was not pressed', () => {
machine.setBindings([entry('history-popup', kb(0x56, { ctrl: true, shift: true }))])
machine.keyDown(keyOf(0x41))
machine.keyUp(keyOf(0x41), 0x41)
machine.keyUp(keyOf(0x56, { ctrl: true, shift: true }), 0x56)
expect(events).toEqual([])
})
it('deduplicates the same action holding the same binding twice', () => {
const b = kb(0x56, { ctrl: true, shift: true })
machine.setBindings([entry('history-popup', b), entry('history-popup', b)])
expect(machine.registeredBindingCount).toBe(1)
machine.keyDown(bindingKey(b))
expect(events.map(label)).toEqual(['history-popup:pressed'])
})
})
describe('ChordStateMachine — double press', () => {
beforeEach(() => {
machine.setBindings([
entry('dictation', kb(VK.AltRight), { holdMode: true }),
entry('hands-free', kb(VK.AltRight), { doublePress: true })
])
})
it('splits a shared key into single (dictation) and double (hands-free) presses', () => {
const key = keyOf(VK.AltRight, { alt: true })
machine.keyDown(key)
clock.advance(80)
machine.keyUp(key, VK.AltRight)
clock.advance(100)
machine.keyDown(key)
clock.advance(50)
machine.keyUp(key, VK.AltRight)
expect(events.map(label)).toEqual([
'dictation:pressed',
'dictation:released',
'hands-free:pressed',
'hands-free:released'
])
expect(events[2]?.isDoublePress).toBe(true)
})
it('treats a second press outside the window as a new single press', () => {
const key = keyOf(VK.AltRight)
machine.keyDown(key)
machine.keyUp(key, VK.AltRight)
clock.advance(1_000)
machine.keyDown(key)
expect(events.map(label)).toEqual([
'dictation:pressed',
'dictation:released',
'dictation:pressed'
])
})
it('does not swallow fast repeated presses of a single-press-only binding', () => {
machine.setBindings([entry('history-popup', kb(0x56, { ctrl: true, shift: true }))])
const key = keyOf(0x56, { ctrl: true, shift: true })
machine.keyDown(key)
machine.keyUp(key, 0x56)
clock.advance(50)
machine.keyDown(key)
expect(events.map(label)).toEqual([
'history-popup:pressed',
'history-popup:released',
'history-popup:pressed'
])
})
})
describe('ChordStateMachine — component release', () => {
it('releases Alt+1 when the modifier is let go first', () => {
machine.setBindings([entry('dictation', kb(0x31, { alt: true }), { holdMode: true })])
machine.keyDown(keyOf(0x31, { alt: true }))
// Alt 를 먼저 놓는다: keyup 이벤트는 Alt 자신(수정자 플래그 없음)으로 온다.
machine.keyUp(keyOf(VK.AltLeft), VK.AltLeft)
expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released'])
// 뒤이은 1 키 업은 이미 놓인 조합이므로 아무것도 내보내지 않는다.
machine.keyUp(keyOf(0x31), 0x31)
expect(events).toHaveLength(2)
})
it('releases Alt+1 when the main key comes up without its modifier flag', () => {
machine.setBindings([entry('dictation', kb(0x31, { alt: true }), { holdMode: true })])
machine.keyDown(keyOf(0x31, { alt: true }))
machine.keyUp(keyOf(0x31), 0x31)
expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released'])
})
it('releases a Ctrl+mouse binding when Ctrl is let go first', () => {
const mouse: KeyBinding = { ...kb(MouseButton.Back, { ctrl: true }), device: 'mouse' }
machine.setBindings([entry('dictation', mouse, { holdMode: true })])
machine.keyDown(bindingKey(mouse))
machine.keyUp(keyOf(VK.CtrlLeft), VK.CtrlLeft)
expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released'])
})
it('matches mouse-up exactly and never by component', () => {
const mouse: KeyBinding = { ...kb(MouseButton.Back, { ctrl: true }), device: 'mouse' }
machine.setBindings([entry('dictation', mouse, { holdMode: true })])
machine.keyDown(bindingKey(mouse))
// 수정자 없는 mouseup — 정확 매칭 실패, 구성 키 검사도 하지 않는다.
machine.keyUp(bindingKey({ ...mouse, ctrl: false }), null)
expect(events.map(label)).toEqual(['dictation:pressed'])
machine.keyUp(bindingKey(mouse), null)
expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released'])
})
it('does not release on an unrelated key-up', () => {
machine.setBindings([entry('dictation', kb(0x31, { alt: true }), { holdMode: true })])
machine.keyDown(keyOf(0x31, { alt: true }))
machine.keyUp(keyOf(VK.ShiftLeft, { alt: true }), VK.ShiftLeft)
machine.keyUp(null, null)
expect(events.map(label)).toEqual(['dictation:pressed'])
})
})
// ============================================================
// AltGr 가드 (회귀)
// ============================================================
describe('ChordStateMachine — AltGr chord guard', () => {
const ctrlRightAlt = keyOf(VK.AltRight, { ctrl: true, alt: true })
const ctrlAltDown = keyOf(VK.ArrowDown, { ctrl: true, alt: true })
it('with default bindings, Ctrl+RightAlt then ArrowDown fires only suggestion-next', () => {
machine.setBindings(defaultEntries())
machine.keyDown(ctrlRightAlt)
expect(events).toEqual([]) // 보류 — 곧 화살표가 이어질 수 있다
clock.advance(60)
machine.keyDown(ctrlAltDown)
clock.advance(1_000)
machine.keyUp(ctrlAltDown, VK.ArrowDown)
machine.keyUp(ctrlRightAlt, VK.AltRight)
clock.advance(1_000)
expect(events.map(label)).toEqual(['suggestion-next:pressed', 'suggestion-next:released'])
expect(clock.activeTimerCount).toBe(0)
})
it('cycling suggestions while Ctrl+Alt stays held never starts the command action', () => {
machine.setBindings(defaultEntries())
machine.keyDown(ctrlRightAlt)
for (let i = 0; i < 3; i += 1) {
clock.advance(40)
machine.keyDown(ctrlAltDown)
clock.advance(40)
machine.keyUp(ctrlAltDown, VK.ArrowDown)
}
clock.advance(2_000)
machine.keyUp(ctrlRightAlt, VK.AltRight)
expect(events.some((e) => e.actionId === 'command')).toBe(false)
})
it('a held default Ctrl+RightAlt fires pressed after the grace and released on key-up', () => {
machine.setBindings(defaultEntries())
machine.keyDown(ctrlRightAlt)
clock.advance(ALTGR_CHORD_GRACE_MS - 1)
expect(events).toEqual([])
clock.advance(1)
expect(events.map(label)).toEqual(['command:pressed'])
clock.advance(1_000 - ALTGR_CHORD_GRACE_MS)
machine.keyUp(ctrlRightAlt, VK.AltRight)
expect(events.map(label)).toEqual(['command:pressed', 'command:released'])
expect(events[1]?.durationMs).toBe(1_000)
})
it('a rebound hold-to-talk Ctrl+Alt+RightShift gets its released event', () => {
machine.setBindings([
entry('dictation', kb(VK.ShiftRight, { ctrl: true, alt: true }), { holdMode: true })
])
const key = keyOf(VK.ShiftRight, { ctrl: true, alt: true, shift: true })
machine.keyDown(key)
clock.advance(1_000)
machine.keyUp(key, VK.ShiftRight)
expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released'])
expect(events[1]?.durationMs).toBe(1_000)
})
it('a tap shorter than the grace emits pressed then released in order, never after key-up', () => {
machine.setBindings([
entry('dictation', kb(VK.ShiftRight, { ctrl: true, alt: true }), { holdMode: true })
])
const key = keyOf(VK.ShiftRight, { ctrl: true, alt: true, shift: true })
machine.keyDown(key)
clock.advance(100)
machine.keyUp(key, VK.ShiftRight)
expect(events.map(label)).toEqual(['dictation:pressed', 'dictation:released'])
clock.advance(1_000)
expect(events).toHaveLength(2) // 늦게 도착하는 pressed 가 없다
expect(clock.activeTimerCount).toBe(0)
})
it('keeps every action that shares an AltGr-shaped binding pending, not just the last', () => {
const shared = kb(VK.AltRight, { ctrl: true })
machine.setBindings([entry('command', shared), entry('caption', shared)])
machine.keyDown(ctrlRightAlt)
clock.advance(ALTGR_CHORD_GRACE_MS)
expect(events.map(label).sort()).toEqual(['caption:pressed', 'command:pressed'])
})
it('does not defer bindings that are not AltGr-shaped', () => {
machine.setBindings(defaultEntries())
machine.keyDown(keyOf(VK.AltRight, { alt: true }))
expect(events.map(label)).toEqual(['dictation:pressed'])
})
it('a cancelled deferral does not leak into the next press of the same chord', () => {
machine.setBindings(defaultEntries())
machine.keyDown(ctrlRightAlt)
machine.keyDown(ctrlAltDown)
machine.keyUp(ctrlAltDown, VK.ArrowDown)
machine.keyUp(ctrlRightAlt, VK.AltRight)
events = []
clock.advance(1_000)
machine.keyDown(ctrlRightAlt)
clock.advance(ALTGR_CHORD_GRACE_MS + 200)
machine.keyUp(ctrlRightAlt, VK.AltRight)
expect(events.map(label)).toEqual(['command:pressed', 'command:released'])
})
})
// ============================================================
// reset / setBindings
// ============================================================
describe('ChordStateMachine — reset', () => {
it('reset drops a pending AltGr emit and all held state', () => {
machine.setBindings(defaultEntries())
machine.keyDown(keyOf(VK.AltRight, { ctrl: true, alt: true }))
machine.reset()
clock.advance(1_000)
expect(events).toEqual([])
expect(clock.activeTimerCount).toBe(0)
// 눌림 상태도 비워졌으므로 같은 키를 다시 누를 수 있다.
machine.keyDown(keyOf(VK.AltRight, { alt: true }))
expect(events.map(label)).toEqual(['dictation:pressed'])
})
it('setBindings resets pending timers and the double-press window', () => {
machine.setBindings(defaultEntries())
machine.keyDown(keyOf(VK.AltRight, { ctrl: true, alt: true }))
machine.setBindings(defaultEntries())
clock.advance(1_000)
expect(events).toEqual([])
const single = keyOf(VK.AltRight)
machine.keyDown(single)
machine.keyUp(single, VK.AltRight)
machine.setBindings(defaultEntries())
clock.advance(50)
machine.keyDown(single)
// 재로드 뒤라 더블프레스로 보지 않는다.
expect(events.map(label)).toEqual([
'dictation:pressed',
'dictation:released',
'dictation:pressed'
])
})
it('registeredBindings returns one normalized binding per key', () => {
machine.setBindings(defaultEntries())
const keys = machine.registeredBindings().map(bindingKey)
expect(new Set(keys).size).toBe(keys.length)
expect(keys.length).toBe(machine.registeredBindingCount)
// dictation / hands-free 가 공유하는 Right Alt 는 한 번만 나온다.
expect(keys.filter((k) => k === keyOf(VK.AltRight))).toHaveLength(1)
})
})