// 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> = {} ): 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) }) })