refactor(keybinding): extract chord state machine into core and fix AltGr guard
This commit is contained in:
parent
de1e8a82a4
commit
9cd81b48c1
3 changed files with 960 additions and 355 deletions
479
packages/core/__tests__/keybinding-runtime.test.ts
Normal file
479
packages/core/__tests__/keybinding-runtime.test.ts
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
// 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue