// packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts // 레드팀 r3-8 회귀 테스트 — AltGr 배열(독일어·프랑스어·폴란드어·북유럽). // // Windows 는 AltGr 를 "가짜 LCtrl 눌림 → RAlt 눌림"(같은 타임스탬프)으로 보내고, libuiohook 은 // 가짜 LCtrl 로 Ctrl 수정자를 켠다. 그래서 // 1) '@'(AltGr+Q) 를 칠 때마다 Ctrl+RightAlt('command') 가 발동해 음성 세션이 열렸고, // 2) 기본 dictation(RightAlt 단독) · hands-free(더블탭) 는 영영 발동하지 않았다. // 데스크톱 어댑터와 같은 순서(AltGrPressDetector → noteKeyDown → keyDown)로 흘려 검증한다. import { describe, it, expect, beforeEach } from 'vitest' import { ALTGR_CHORD_GRACE_MS, AltGrPressDetector, ChordStateMachine } from '../src/keybinding-runtime' import type { ChordBindingEntry, ChordClock, ChordTriggerEvent } from '../src/keybinding-runtime' import { KEYBINDING_ACTIONS, VK, bindingKey, createDefaultBindingMap } from '../src/keybinding' const VK_Q = 0x51 // ============================================================ // 가짜 시계 // ============================================================ class FakeClock implements ChordClock { private _now = 1_000 private _timers: { at: number; callback: () => void; active: boolean }[] = [] now(): number { return this._now } schedule(callback: () => void, delayMs: number): () => void { const timer = { at: this._now + delayMs, callback, active: true } this._timers.push(timer) return () => { timer.active = false } } 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 this._now = due.at due.active = false due.callback() } this._timers = this._timers.filter((t) => t.active) this._now = target } } // ============================================================ // 어댑터 흉내 (KeyBindingService._handleKeyDown / _handleKeyUp 와 같은 순서) // ============================================================ interface Mods { ctrl?: boolean alt?: boolean } function defaultEntries(): ChordBindingEntry[] { const map = createDefaultBindingMap() return KEYBINDING_ACTIONS.flatMap((spec) => map[spec.id].map((binding) => ({ actionId: spec.id, holdMode: spec.holdMode, doublePress: spec.doublePress, binding })) ) } class Harness { readonly clock = new FakeClock() readonly events: string[] = [] readonly detector = new AltGrPressDetector() readonly machine = new ChordStateMachine({ clock: this.clock, onTrigger: (e: ChordTriggerEvent) => this.events.push(`${e.actionId}:${e.type}`) }) constructor() { this.machine.setBindings(defaultEntries()) } private _key(vk: number, mods: Mods, isAltGr: boolean): string { return bindingKey({ device: 'keyboard', code: vk, ctrl: (mods.ctrl ?? false) && !isAltGr, alt: mods.alt ?? false, shift: false, meta: false }) } down(vk: number, mods: Mods = {}): void { const isAltGr = this.detector.keyDown(vk, this.clock.now()) this.machine.noteKeyDown(vk) this.machine.keyDown(this._key(vk, mods, isAltGr), { altGr: isAltGr }) } up(vk: number, mods: Mods = {}): void { this.detector.keyUp(vk) this.machine.keyUp(this._key(vk, mods, false), vk) } /** AltGr 누름: 가짜 LCtrl → RAlt (같은 타임스탬프, RAlt 에는 Ctrl+Alt 가 실린다) */ altGrDown(): void { this.down(VK.CtrlLeft, { ctrl: true }) this.down(VK.AltRight, { ctrl: true, alt: true }) } /** AltGr 놓음: 가짜 LCtrl ↑ → RAlt ↑ */ altGrUp(): void { this.up(VK.CtrlLeft, { alt: true }) this.up(VK.AltRight) } wait(ms: number): void { this.clock.advance(ms) } } describe('AltGr 배열 — 문자 입력은 음성 트리거를 발동하지 않는다', () => { let h: Harness beforeEach(() => { h = new Harness() }) it("AltGr+Q('@') 를 치면 어떤 트리거도 나가지 않는다", () => { h.altGrDown() h.wait(40) h.down(VK_Q, { ctrl: true, alt: true }) h.up(VK_Q, { ctrl: true, alt: true }) h.wait(20) h.altGrUp() h.wait(1_000) expect(h.events).toEqual([]) }) it("AltGr 를 700ms 넘게 누른 채 '{' '}' 를 쳐도 트리거가 나가지 않는다", () => { h.altGrDown() h.wait(100) h.down(0x37, { ctrl: true, alt: true }) // 7 → '{' h.up(0x37, { ctrl: true, alt: true }) h.wait(300) h.down(0x30, { ctrl: true, alt: true }) // 0 → '}' h.up(0x30, { ctrl: true, alt: true }) h.wait(400) h.altGrUp() expect(h.events).toEqual([]) }) it('좌표계로 옮기지 못한 문자 키(vk=null, 예: VK_OEM_102 "<")도 보류를 취소한다', () => { h.altGrDown() h.wait(30) h.detector.keyDown(null, h.clock.now()) h.machine.noteKeyDown(null) h.wait(30) h.altGrUp() h.wait(1_000) expect(h.events).toEqual([]) }) it('판별기가 AltGr 를 놓쳐도(타임스탬프 불일치) 문자 키가 Ctrl+RightAlt 보류를 취소한다', () => { h.down(VK.CtrlLeft, { ctrl: true }) h.wait(5) h.down(VK.AltRight, { ctrl: true, alt: true }) // 'command' 로 보임 → AltGr 형태 보류 h.down(VK_Q, { ctrl: true, alt: true }) h.up(VK_Q, { ctrl: true, alt: true }) h.up(VK.CtrlLeft, { alt: true }) h.up(VK.AltRight) h.wait(1_000) expect(h.events).toEqual([]) }) }) describe('AltGr 배열 — 기본 dictation · hands-free 가 발동한다', () => { let h: Harness beforeEach(() => { h = new Harness() }) it('AltGr 를 누르고 있으면 유예 뒤 dictation 이 pressed, 놓으면 released', () => { h.altGrDown() expect(h.events).toEqual([]) h.wait(ALTGR_CHORD_GRACE_MS) expect(h.events).toEqual(['dictation:pressed']) h.wait(1_500) h.altGrUp() expect(h.events).toEqual(['dictation:pressed', 'dictation:released']) }) it('AltGr auto-repeat(가짜 LCtrl + RAlt 반복)는 누름을 다시 트리거하지 않는다', () => { h.altGrDown() h.wait(ALTGR_CHORD_GRACE_MS) h.wait(500) h.altGrDown() h.wait(33) h.altGrDown() h.altGrUp() expect(h.events).toEqual(['dictation:pressed', 'dictation:released']) }) it('AltGr 더블탭은 hands-free 를 켠다', () => { h.altGrDown() h.wait(60) h.altGrUp() h.wait(120) h.altGrDown() h.wait(60) h.altGrUp() expect(h.events).toEqual([ 'dictation:pressed', 'dictation:released', 'hands-free:pressed', 'hands-free:released' ]) }) it("'@' 직후 AltGr 를 탭해도 hands-free 로 세지 않는다 (취소된 누름은 첫 탭이 아니다)", () => { h.altGrDown() h.down(VK_Q, { ctrl: true, alt: true }) h.up(VK_Q, { ctrl: true, alt: true }) h.altGrUp() h.wait(100) h.altGrDown() h.wait(60) h.altGrUp() expect(h.events).toEqual(['dictation:pressed', 'dictation:released']) }) it('진짜 LCtrl 을 먼저 누르고 AltGr 를 누르면 command(Ctrl+RightAlt) 다', () => { h.down(VK.CtrlLeft, { ctrl: true }) h.wait(80) h.altGrDown() // Windows 는 LCtrl 이 눌려 있어도 가짜 LCtrl 을 보낸다 h.wait(ALTGR_CHORD_GRACE_MS) expect(h.events).toEqual(['command:pressed']) h.up(VK.AltRight, { ctrl: true }) h.up(VK.CtrlLeft) expect(h.events).toEqual(['command:pressed', 'command:released']) }) }) describe('AltGrPressDetector', () => { it('같은 타임스탬프의 LCtrl 직후 RAlt 만 AltGr 다', () => { const d = new AltGrPressDetector() expect(d.keyDown(VK.CtrlLeft, 10)).toBe(false) expect(d.keyDown(VK.AltRight, 10)).toBe(true) }) it('타임스탬프가 다르거나 사이에 다른 키가 끼면 AltGr 가 아니다', () => { const a = new AltGrPressDetector() a.keyDown(VK.CtrlLeft, 10) expect(a.keyDown(VK.AltRight, 26)).toBe(false) const b = new AltGrPressDetector() b.keyDown(VK.CtrlLeft, 10) b.keyDown(VK_Q, 10) expect(b.keyDown(VK.AltRight, 10)).toBe(false) }) it('RAlt 를 놓으면 다음 누름은 새로 판정한다', () => { const d = new AltGrPressDetector() d.keyDown(VK.CtrlLeft, 10) expect(d.keyDown(VK.AltRight, 10)).toBe(true) d.keyUp(VK.CtrlLeft) d.keyUp(VK.AltRight) expect(d.keyDown(VK.AltRight, 500)).toBe(false) }) it('reset 은 상태를 비운다', () => { const d = new AltGrPressDetector() d.keyDown(VK.CtrlLeft, 10) d.reset() expect(d.keyDown(VK.AltRight, 10)).toBe(false) }) })