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.
286 lines
8.5 KiB
TypeScript
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)
|
|
})
|
|
})
|