d3ro-voice/packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

286 lines
8.5 KiB
TypeScript

// 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)
})
})