d3ro-voice/packages/core/__tests__/keybinding-i18n.test.ts
Yun Chan 4ad1ae6ed4 feat(keybinding): several shortcuts per action, mouse buttons, searchable picker
Shortcuts were defined in four places that drifted apart: per-action IPC channel
pairs, a hand-written VK table in the service, a second one in the renderer, and
three copies of the keycap styling. Adding an action meant editing all of them,
so two shortcuts stayed hardcoded in bootstrap and one had no settings entry at
all.

packages/core/src/keybinding.ts is now the single source for the binding type,
the selectable key catalog, the action catalog, normalization, validation,
conflict detection, display labels, search and deserialization. Main, preload
and renderer all read from it; nothing redefines keys or rules locally.

- Each action holds a list of bindings instead of one. AppConfig's four
  *Shortcut fields collapse into a single keyBindings map, migrated on launch.
- Mouse buttons can be bound. Left click is refused, right/middle need a
  modifier, side buttons are free. uiohook cannot swallow events, so the
  original click still fires and the UI says so.
- Keys can be picked from a grouped dropdown with a search box, not only by
  recording a keypress.
- HOTKEY's 14 channels become KEYBINDING's 9, taking the action as a parameter,
  so actions no longer multiply channels. The history and command popups moved
  out of bootstrap into ordinary actions.
- displayLabel is gone; labels derive from the binding and follow the app
  language and platform.

Fixes found on the way:
- Double-press hands-free was unreachable: lookup returned only the first
  matching action, and dictation shares its default binding.
- Reserved-combination checks compared joined key names, so a different modifier
  order let Ctrl+C through.
- Disabling shortcuts released every global registration in the process,
  including the popup ones, and never restored them.
- Enabling shortcuts after starting disabled left nothing registered.
- The dashboard stored the caption event payload instead of the state in it.
2026-09-21 13:41:47 +09:00

310 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// packages/core/__tests__/keybinding-i18n.test.ts
// 키바인딩 SSOT 가 참조하는 i18n 키가 실제 로케일 JSON 에 존재하는지 잠근다.
//
// keybinding.ts 는 i18n 키를 평범한 string 으로 노출한다 — 코어가 로케일 패키지에 의존하지
// 않게 하려는 의도다. 그래서 렌더러는 `as TranslationKey` 로 좁혀 쓰고, 캐스팅이라
// 존재하지 않는 키를 넘겨도 컴파일이 잡지 못한다. 런타임에 키 문자열이 그대로 화면에 노출된다.
//
// 그 구멍을 여기서 막는다. 소스에 i18n 의존을 들이지 않기 위해 JSON 을 직접 읽으며,
// 이 파일에서만 그렇게 한다.
//
// 검증 대상은 **core 가 참조하는 키만**이다. 렌더러 전용 `keybinding.ui.*` 는 대상이 아니다.
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, it, expect } from 'vitest'
import {
KEYBINDING_ACTIONS,
KEY_CATALOG,
KEY_CATALOG_GROUP_LABEL_KEYS,
validateBinding
} from '../src/keybinding'
import type { BindingDevice, BindingRejectReason, KeyBinding } from '../src/keybinding'
// ------------------------------------------------------------
// 로케일 JSON 접근
// ------------------------------------------------------------
const I18N_SRC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'i18n', 'src')
const LOCALES_DIR = join(I18N_SRC_DIR, 'locales')
/** 마스터 로케일. `TranslationKey = keyof typeof ko` 이므로 ko 가 키 집합의 정본이다. */
const MASTER_LOCALE = 'ko'
/** 렌더러 전용 키 접두사 — core 는 이걸 참조하지 않아야 한다. */
const RENDERER_ONLY_PREFIX = 'keybinding.ui.'
function localeNames(): string[] {
return readdirSync(LOCALES_DIR)
.filter((file) => file.endsWith('.json'))
.map((file) => file.slice(0, -'.json'.length))
.sort()
}
function readLocale(name: string): Record<string, string> {
const parsed: unknown = JSON.parse(readFileSync(join(LOCALES_DIR, `${name}.json`), 'utf8'))
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`${name}.json 의 최상위가 평면 객체가 아니다`)
}
const table: Record<string, string> = {}
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== 'string') {
throw new Error(`${name}.json 의 "${key}" 값이 문자열이 아니다`)
}
table[key] = value
}
return table
}
// ------------------------------------------------------------
// core 가 참조하는 i18n 키 수집
// ------------------------------------------------------------
/** 카탈로그 · 그룹 · 액션 상수가 선언적으로 들고 있는 키. */
function declaredI18nKeys(): Set<string> {
const keys = new Set<string>()
for (const entry of KEY_CATALOG) {
if (entry.labelKey !== undefined) keys.add(entry.labelKey)
if (entry.disabledReasonKey !== undefined) keys.add(entry.disabledReasonKey)
if (entry.passthroughWarningKey !== undefined) keys.add(entry.passthroughWarningKey)
}
for (const groupLabelKey of Object.values(KEY_CATALOG_GROUP_LABEL_KEYS)) {
keys.add(groupLabelKey)
}
for (const action of KEYBINDING_ACTIONS) {
keys.add(action.labelKey)
keys.add(action.descriptionKey)
}
return keys
}
type ModFlags = Partial<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>>
/** 거부·경고 경로를 폭넓게 태우기 위한 수정자 조합. */
const MOD_COMBOS: readonly ModFlags[] = [
{},
{ ctrl: true },
{ alt: true },
{ shift: true },
{ meta: true },
{ ctrl: true, alt: true },
{ ctrl: true, shift: true },
{ ctrl: true, alt: true, shift: true, meta: true }
]
/** 어느 카탈로그 그룹에도 없는 코드 — unknown-key 경로용 */
const UNKNOWN_VK = 0x99
const UNKNOWN_MOUSE = 9
function probe(device: BindingDevice, code: number, mods: ModFlags): KeyBinding {
return {
device,
code,
ctrl: mods.ctrl ?? false,
alt: mods.alt ?? false,
shift: mods.shift ?? false,
meta: mods.meta ?? false
}
}
interface ValidationSweep {
reasonKeys: Set<string>
warningKeys: Set<string>
reasons: Set<BindingRejectReason>
}
/**
* 카탈로그 전 엔트리 × 수정자 조합 + 카탈로그 밖 코드를 실제로 validateBinding 에 태워
* 반환된 i18n 키를 수집한다.
*
* 하드코딩 목록을 쓰지 않는 이유: 거부 사유나 카탈로그 항목이 늘어나면 그 키가 자동으로
* 수집 대상에 들어와야 한다.
*/
function sweepValidation(): ValidationSweep {
const sweep: ValidationSweep = {
reasonKeys: new Set<string>(),
warningKeys: new Set<string>(),
reasons: new Set<BindingRejectReason>()
}
const probes: KeyBinding[] = []
for (const entry of KEY_CATALOG) {
for (const mods of MOD_COMBOS) {
probes.push(probe(entry.device, entry.code, mods))
}
}
for (const mods of MOD_COMBOS) {
probes.push(probe('keyboard', UNKNOWN_VK, mods))
probes.push(probe('mouse', UNKNOWN_MOUSE, mods))
}
for (const binding of probes) {
const result = validateBinding(binding)
if (result.reasonKey !== null) sweep.reasonKeys.add(result.reasonKey)
if (result.warningKey !== null) sweep.warningKeys.add(result.warningKey)
if (result.reason !== null) sweep.reasons.add(result.reason)
}
return sweep
}
/** core 가 참조하는 i18n 키 전부 (선언 + 런타임 반환값). */
function allCoreI18nKeys(): string[] {
const sweep = sweepValidation()
const keys = new Set<string>([
...declaredI18nKeys(),
...sweep.reasonKeys,
...sweep.warningKeys
])
return [...keys].sort()
}
// ------------------------------------------------------------
// 수집기 자체가 비어있지 않은지 (이 아래 검증이 공회전하지 않게)
// ------------------------------------------------------------
describe('i18n 키 수집기', () => {
it('선언된 키를 실제로 모은다', () => {
const declared = declaredI18nKeys()
expect(declared.size).toBeGreaterThan(0)
for (const action of KEYBINDING_ACTIONS) {
expect(declared.has(action.labelKey), action.id).toBe(true)
expect(declared.has(action.descriptionKey), action.id).toBe(true)
}
for (const groupLabelKey of Object.values(KEY_CATALOG_GROUP_LABEL_KEYS)) {
expect(declared.has(groupLabelKey), groupLabelKey).toBe(true)
}
})
it('검증 스윕이 모든 거부 사유 경로를 실제로 태운다', () => {
const sweep = sweepValidation()
const reasons: BindingRejectReason[] = [
'device-disabled',
'modifier-required',
'system-reserved',
'unknown-key'
]
expect([...sweep.reasons].sort()).toEqual(reasons)
expect(sweep.reasonKeys.size).toBe(reasons.length)
expect(sweep.warningKeys.size).toBeGreaterThan(0)
})
it('수집한 키가 전부 keybinding 네임스페이스에 있다', () => {
const keys = allCoreI18nKeys()
expect(keys.length).toBeGreaterThan(0)
for (const key of keys) {
expect(key.startsWith('keybinding.'), key).toBe(true)
}
})
it('수집한 키 수가 계약 상수 규모에 못 미치지 않는다', () => {
// 액션당 라벨·설명 2개 + 그룹 라벨 전부는 최소한 들어와야 한다.
const floor =
KEYBINDING_ACTIONS.length * 2 + Object.keys(KEY_CATALOG_GROUP_LABEL_KEYS).length
expect(allCoreI18nKeys().length).toBeGreaterThanOrEqual(floor)
})
it('core 는 렌더러 전용 keybinding.ui.* 를 참조하지 않는다', () => {
const rendererKeys = allCoreI18nKeys().filter((key) =>
key.startsWith(RENDERER_ONLY_PREFIX)
)
expect(rendererKeys).toEqual([])
})
})
// ------------------------------------------------------------
// 마스터 로케일 (ko)
// ------------------------------------------------------------
describe('키바인딩 i18n 키 — 마스터 로케일 ko', () => {
it('카탈로그·그룹·액션이 선언한 키가 전부 ko.json 에 있다', () => {
const table = readLocale(MASTER_LOCALE)
const missing = [...declaredI18nKeys()].sort().filter((key) => !(key in table))
expect(missing).toEqual([])
})
it('validateBinding 이 실제로 반환하는 reasonKey 가 전부 ko.json 에 있다', () => {
const table = readLocale(MASTER_LOCALE)
const missing = [...sweepValidation().reasonKeys].sort().filter((key) => !(key in table))
expect(missing).toEqual([])
})
it('validateBinding 이 실제로 반환하는 warningKey 가 전부 ko.json 에 있다', () => {
const table = readLocale(MASTER_LOCALE)
const missing = [...sweepValidation().warningKeys].sort().filter((key) => !(key in table))
expect(missing).toEqual([])
})
it('ko.json 의 해당 값이 빈 문자열이 아니다', () => {
const table = readLocale(MASTER_LOCALE)
const blank = allCoreI18nKeys().filter((key) => {
const value = table[key]
return value !== undefined && value.trim() === ''
})
expect(blank).toEqual([])
})
})
// ------------------------------------------------------------
// 전체 로케일
// ------------------------------------------------------------
describe('키바인딩 i18n 키 — 전체 로케일', () => {
it('마스터 로케일 ko 를 포함해 로케일이 복수로 존재한다', () => {
const names = localeNames()
expect(names).toContain(MASTER_LOCALE)
expect(names.length).toBeGreaterThan(1)
})
it('로케일 디렉터리의 파일이 전부 index.tsx 에 등록되어 있다', () => {
const source = readFileSync(join(I18N_SRC_DIR, 'index.tsx'), 'utf8')
const unregistered = localeNames().filter(
(name) => !source.includes(`./locales/${name}.json`)
)
expect(unregistered).toEqual([])
})
it('모든 로케일이 core 참조 키를 빠짐없이 갖는다', () => {
const keys = allCoreI18nKeys()
const missing: string[] = []
for (const name of localeNames()) {
const table = readLocale(name)
for (const key of keys) {
if (!(key in table)) missing.push(`${name}: ${key}`)
}
}
expect(missing).toEqual([])
})
it('모든 로케일이 ko 와 동일한 core 참조 키 집합을 갖는다', () => {
const keys = allCoreI18nKeys()
const master = readLocale(MASTER_LOCALE)
const masterSubset = keys.filter((key) => key in master)
const mismatched: string[] = []
for (const name of localeNames()) {
const table = readLocale(name)
const subset = keys.filter((key) => key in table)
if (subset.join('\n') !== masterSubset.join('\n')) {
const missing = masterSubset.filter((key) => !subset.includes(key))
const extra = subset.filter((key) => !masterSubset.includes(key))
mismatched.push(
`${name}: missing=[${missing.join(', ')}] extra=[${extra.join(', ')}]`
)
}
}
expect(mismatched).toEqual([])
})
it('모든 로케일에서 core 참조 키의 값이 빈 문자열이 아니다', () => {
const keys = allCoreI18nKeys()
const blank: string[] = []
for (const name of localeNames()) {
const table = readLocale(name)
for (const key of keys) {
const value = table[key]
if (value !== undefined && value.trim() === '') blank.push(`${name}: ${key}`)
}
}
expect(blank).toEqual([])
})
})