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.
1083 lines
38 KiB
TypeScript
1083 lines
38 KiB
TypeScript
// packages/core/__tests__/keybinding.test.ts
|
|
// 키바인딩 SSOT(src/keybinding.ts) 계약 검증.
|
|
// 정규화 · 동일성 · 검증 정책 · 충돌 판정 · 카탈로그 불변식 · 역직렬화 · 표시 라벨.
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
KEYBINDING_ACTIONS,
|
|
KEY_CATALOG,
|
|
KEY_CATALOG_GROUP_LABEL_KEYS,
|
|
KEY_CATALOG_GROUP_ORDER,
|
|
MouseButton,
|
|
VK,
|
|
bindingKey,
|
|
bindingsEqual,
|
|
createDefaultBindingMap,
|
|
detectBindingConflicts,
|
|
findActionSpec,
|
|
findKeyCatalogEntry,
|
|
formatBindingSegments,
|
|
hasModifier,
|
|
isKeyBinding,
|
|
isKeyBindingActionId,
|
|
isModifierKeyCode,
|
|
joinBindingSegments,
|
|
normalizeBinding,
|
|
parseBindingList,
|
|
parseBindingMap,
|
|
searchKeyCatalog,
|
|
validateBinding
|
|
} from '../src/keybinding'
|
|
import type {
|
|
BindingPlatform,
|
|
KeyBinding,
|
|
KeyBindingActionId,
|
|
KeyCatalogGroup
|
|
} from '../src/keybinding'
|
|
|
|
// ------------------------------------------------------------
|
|
// 헬퍼
|
|
// ------------------------------------------------------------
|
|
|
|
type ModFlags = Partial<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>>
|
|
type ModName = keyof ModFlags
|
|
|
|
const MOD_NAMES: readonly ModName[] = ['ctrl', 'alt', 'shift', 'meta']
|
|
|
|
/** 기능 키는 VK 에 F1/F24 만 있으므로 오프셋으로 만든다. */
|
|
const F4 = VK.F1 + 3
|
|
const F5 = VK.F1 + 4
|
|
const F6 = VK.F1 + 5
|
|
const F8 = VK.F1 + 7
|
|
const F9 = VK.F1 + 8
|
|
const F12 = VK.F1 + 11
|
|
|
|
/** 문자 키 VK (카탈로그 범위 안) */
|
|
const KEY_A = 0x41
|
|
const KEY_C = 0x43
|
|
const KEY_S = 0x53
|
|
const KEY_V = 0x56
|
|
const KEY_W = 0x57
|
|
const KEY_X = 0x58
|
|
const KEY_Z = 0x5a
|
|
|
|
/** 어느 카탈로그 그룹에도 없는 VK 코드 */
|
|
const UNKNOWN_VK = 0x99
|
|
/** 존재하지 않는 마우스 버튼 코드 */
|
|
const UNKNOWN_MOUSE = 9
|
|
|
|
function key(code: number, mods: ModFlags = {}): KeyBinding {
|
|
return {
|
|
device: 'keyboard',
|
|
code,
|
|
ctrl: mods.ctrl ?? false,
|
|
alt: mods.alt ?? false,
|
|
shift: mods.shift ?? false,
|
|
meta: mods.meta ?? false
|
|
}
|
|
}
|
|
|
|
function mouse(code: number, mods: ModFlags = {}): KeyBinding {
|
|
return {
|
|
device: 'mouse',
|
|
code,
|
|
ctrl: mods.ctrl ?? false,
|
|
alt: mods.alt ?? false,
|
|
shift: mods.shift ?? false,
|
|
meta: mods.meta ?? false
|
|
}
|
|
}
|
|
|
|
function labelsOf(binding: KeyBinding | null, platform: BindingPlatform): string[] {
|
|
return formatBindingSegments(binding, platform).map((segment) => segment.label)
|
|
}
|
|
|
|
function permutations<T>(items: readonly T[]): T[][] {
|
|
if (items.length <= 1) return [[...items]]
|
|
const out: T[][] = []
|
|
for (let i = 0; i < items.length; i += 1) {
|
|
const rest = [...items.slice(0, i), ...items.slice(i + 1)]
|
|
for (const tail of permutations(rest)) out.push([items[i], ...tail])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ------------------------------------------------------------
|
|
// normalizeBinding
|
|
// ------------------------------------------------------------
|
|
|
|
describe('normalizeBinding — 주 키가 수정자 자체이면 그 플래그를 끈다', () => {
|
|
it('Right Alt 를 주 키로 쓰면 alt 플래그가 꺼진다', () => {
|
|
const normalized = normalizeBinding(key(VK.AltRight, { alt: true }))
|
|
expect(normalized.alt).toBe(false)
|
|
expect(normalized.code).toBe(VK.AltRight)
|
|
expect(normalized.device).toBe('keyboard')
|
|
})
|
|
|
|
it('좌우 구분 수정자 코드(0xa0~0xa5)에서 자기 플래그만 꺼진다', () => {
|
|
const cases: readonly (readonly [number, ModName])[] = [
|
|
[VK.ShiftLeft, 'shift'],
|
|
[VK.ShiftRight, 'shift'],
|
|
[VK.CtrlLeft, 'ctrl'],
|
|
[VK.CtrlRight, 'ctrl'],
|
|
[VK.AltLeft, 'alt'],
|
|
[VK.AltRight, 'alt']
|
|
]
|
|
for (const [code, self] of cases) {
|
|
const normalized = normalizeBinding(
|
|
key(code, { ctrl: true, alt: true, shift: true, meta: true })
|
|
)
|
|
expect(normalized[self], `code=0x${code.toString(16)} self=${self}`).toBe(false)
|
|
for (const other of MOD_NAMES) {
|
|
if (other === self) continue
|
|
expect(normalized[other], `code=0x${code.toString(16)} other=${other}`).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('브라우저 비구분 코드(0x10/0x11/0x12)에서도 자기 플래그가 꺼진다', () => {
|
|
const cases: readonly (readonly [number, ModName])[] = [
|
|
[0x10, 'shift'],
|
|
[0x11, 'ctrl'],
|
|
[0x12, 'alt']
|
|
]
|
|
for (const [code, self] of cases) {
|
|
const normalized = normalizeBinding(
|
|
key(code, { ctrl: true, alt: true, shift: true, meta: true })
|
|
)
|
|
expect(normalized[self], `code=0x${code.toString(16)}`).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('Meta(0x5b/0x5c)도 동일하게 동작한다', () => {
|
|
for (const code of [VK.MetaLeft, VK.MetaRight]) {
|
|
const normalized = normalizeBinding(key(code, { meta: true, ctrl: true }))
|
|
expect(normalized.meta, `code=0x${code.toString(16)}`).toBe(false)
|
|
expect(normalized.ctrl).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('자기 자신이 아닌 수정자는 보존된다', () => {
|
|
const normalized = normalizeBinding(key(VK.AltRight, { ctrl: true, alt: true }))
|
|
expect(normalized.ctrl).toBe(true)
|
|
expect(normalized.alt).toBe(false)
|
|
expect(normalized.shift).toBe(false)
|
|
expect(normalized.meta).toBe(false)
|
|
})
|
|
|
|
it('수정자가 아닌 주 키는 플래그를 그대로 둔다', () => {
|
|
const normalized = normalizeBinding(key(KEY_A, { ctrl: true, shift: true }))
|
|
expect(normalized.ctrl).toBe(true)
|
|
expect(normalized.shift).toBe(true)
|
|
})
|
|
|
|
it('마우스 바인딩은 수정자 플래그를 건드리지 않는다', () => {
|
|
const normalized = normalizeBinding(mouse(MouseButton.Back, { alt: true }))
|
|
expect(normalized.device).toBe('mouse')
|
|
expect(normalized.alt).toBe(true)
|
|
})
|
|
|
|
it('알 수 없는 device 는 keyboard 로 취급한다', () => {
|
|
const raw = {
|
|
device: 'gamepad',
|
|
code: KEY_A,
|
|
ctrl: false,
|
|
alt: false,
|
|
shift: false,
|
|
meta: false
|
|
} as unknown as KeyBinding
|
|
expect(normalizeBinding(raw).device).toBe('keyboard')
|
|
})
|
|
|
|
it('원본 객체를 변형하지 않는다', () => {
|
|
const original = key(VK.AltRight, { alt: true })
|
|
normalizeBinding(original)
|
|
expect(original.alt).toBe(true)
|
|
})
|
|
|
|
it('isModifierKeyCode 가 수정자 키 전부를 인식한다', () => {
|
|
const modifierCodes = [
|
|
0x10,
|
|
0x11,
|
|
0x12,
|
|
VK.ShiftLeft,
|
|
VK.ShiftRight,
|
|
VK.CtrlLeft,
|
|
VK.CtrlRight,
|
|
VK.AltLeft,
|
|
VK.AltRight,
|
|
VK.MetaLeft,
|
|
VK.MetaRight
|
|
]
|
|
for (const code of modifierCodes) {
|
|
expect(isModifierKeyCode(code), `code=0x${code.toString(16)}`).toBe(true)
|
|
}
|
|
for (const code of [KEY_A, VK.F1, VK.Space, VK.Escape]) {
|
|
expect(isModifierKeyCode(code), `code=0x${code.toString(16)}`).toBe(false)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ------------------------------------------------------------
|
|
// bindingKey / bindingsEqual / hasModifier
|
|
// ------------------------------------------------------------
|
|
|
|
describe('bindingKey / bindingsEqual — 수정자 순서에 뚫리지 않는다', () => {
|
|
it('수정자를 어떤 순서로 세팅해도 같은 키가 나온다', () => {
|
|
const produced = new Set<string>()
|
|
for (const order of permutations(MOD_NAMES)) {
|
|
const mods: ModFlags = {}
|
|
for (const name of order) mods[name] = true
|
|
produced.add(bindingKey(key(VK.F1, mods)))
|
|
}
|
|
expect(produced.size).toBe(1)
|
|
})
|
|
|
|
it('객체 리터럴의 필드 순서가 달라도 같은 키가 나온다', () => {
|
|
const a: KeyBinding = {
|
|
device: 'keyboard',
|
|
code: VK.Delete,
|
|
ctrl: true,
|
|
alt: true,
|
|
shift: false,
|
|
meta: false
|
|
}
|
|
const b: KeyBinding = {
|
|
device: 'keyboard',
|
|
meta: false,
|
|
shift: false,
|
|
alt: true,
|
|
ctrl: true,
|
|
code: VK.Delete
|
|
}
|
|
expect(bindingKey(a)).toBe(bindingKey(b))
|
|
expect(bindingsEqual(a, b)).toBe(true)
|
|
})
|
|
|
|
it('정규화 전과 후가 같은 키를 낸다', () => {
|
|
const raw = key(VK.AltRight, { alt: true, ctrl: true })
|
|
expect(bindingKey(raw)).toBe(bindingKey(normalizeBinding(raw)))
|
|
expect(bindingsEqual(raw, normalizeBinding(raw))).toBe(true)
|
|
})
|
|
|
|
it('수정자 조합이 다르면 다른 키가 나온다', () => {
|
|
const keys = new Set([
|
|
bindingKey(key(VK.F1)),
|
|
bindingKey(key(VK.F1, { ctrl: true })),
|
|
bindingKey(key(VK.F1, { alt: true })),
|
|
bindingKey(key(VK.F1, { shift: true })),
|
|
bindingKey(key(VK.F1, { meta: true })),
|
|
bindingKey(key(VK.F1, { ctrl: true, alt: true }))
|
|
])
|
|
expect(keys.size).toBe(6)
|
|
})
|
|
|
|
it('keyboard 와 mouse 는 code 가 같아도 다른 키를 낸다', () => {
|
|
expect(bindingKey(key(1))).not.toBe(bindingKey(mouse(1)))
|
|
expect(bindingsEqual(key(1), mouse(1))).toBe(false)
|
|
for (const code of [1, 2, 3, 4, 5]) {
|
|
expect(bindingKey(key(code))).not.toBe(bindingKey(mouse(code)))
|
|
}
|
|
})
|
|
|
|
it('hasModifier 는 자기 자신 수정자를 수정자로 치지 않는다', () => {
|
|
expect(hasModifier(key(VK.AltRight, { alt: true }))).toBe(false)
|
|
expect(hasModifier(key(VK.AltRight, { ctrl: true }))).toBe(true)
|
|
expect(hasModifier(key(KEY_A))).toBe(false)
|
|
expect(hasModifier(key(KEY_A, { shift: true }))).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ------------------------------------------------------------
|
|
// validateBinding
|
|
// ------------------------------------------------------------
|
|
|
|
describe('validateBinding — 마우스 정책', () => {
|
|
it('좌클릭은 수정자 없이 거부된다', () => {
|
|
const result = validateBinding(mouse(MouseButton.Left))
|
|
expect(result.valid).toBe(false)
|
|
expect(result.reason).toBe('device-disabled')
|
|
expect(result.reasonKey).toBe('keybinding.disabled.mouseLeft')
|
|
expect(result.warningKey).toBeNull()
|
|
})
|
|
|
|
it('좌클릭은 수정자가 붙어도 거부된다', () => {
|
|
for (const mods of [
|
|
{ ctrl: true },
|
|
{ alt: true },
|
|
{ shift: true },
|
|
{ meta: true },
|
|
{ ctrl: true, alt: true, shift: true, meta: true }
|
|
]) {
|
|
const result = validateBinding(mouse(MouseButton.Left, mods))
|
|
expect(result.valid, JSON.stringify(mods)).toBe(false)
|
|
expect(result.reason, JSON.stringify(mods)).toBe('device-disabled')
|
|
}
|
|
})
|
|
|
|
it('우클릭 / 가운데 클릭은 수정자가 없으면 거부된다', () => {
|
|
for (const code of [MouseButton.Right, MouseButton.Middle]) {
|
|
const result = validateBinding(mouse(code))
|
|
expect(result.valid, `mouse=${code}`).toBe(false)
|
|
expect(result.reason, `mouse=${code}`).toBe('modifier-required')
|
|
expect(result.reasonKey).toBe('keybinding.reject.modifierRequired')
|
|
}
|
|
})
|
|
|
|
it('우클릭 / 가운데 클릭은 수정자가 있으면 통과하고 패스스루 경고가 붙는다', () => {
|
|
for (const code of [MouseButton.Right, MouseButton.Middle]) {
|
|
const result = validateBinding(mouse(code, { ctrl: true }))
|
|
expect(result.valid, `mouse=${code}`).toBe(true)
|
|
expect(result.reason).toBeNull()
|
|
expect(result.reasonKey).toBeNull()
|
|
expect(result.warningKey).toBe('keybinding.warning.mousePassthrough')
|
|
}
|
|
})
|
|
|
|
it('Back / Forward 는 수정자 없이도 통과하고 패스스루 경고가 붙는다', () => {
|
|
for (const code of [MouseButton.Back, MouseButton.Forward]) {
|
|
const result = validateBinding(mouse(code))
|
|
expect(result.valid, `mouse=${code}`).toBe(true)
|
|
expect(result.reason).toBeNull()
|
|
expect(result.warningKey).toBe('keybinding.warning.mousePassthrough')
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('validateBinding — 키보드 정책', () => {
|
|
it('문자 키는 수정자가 없으면 거부된다', () => {
|
|
for (let code = VK.A; code <= VK.Z; code += 1) {
|
|
const result = validateBinding(key(code))
|
|
expect(result.valid, String.fromCharCode(code)).toBe(false)
|
|
expect(result.reason, String.fromCharCode(code)).toBe('modifier-required')
|
|
}
|
|
})
|
|
|
|
it('문자 키는 수정자가 있으면 통과한다', () => {
|
|
const result = validateBinding(key(KEY_A, { alt: true }))
|
|
expect(result.valid).toBe(true)
|
|
expect(result.warningKey).toBeNull()
|
|
})
|
|
|
|
it('숫자 키는 수정자가 없으면 거부되고 있으면 통과한다', () => {
|
|
for (let code = VK.Digit0; code <= VK.Digit9; code += 1) {
|
|
expect(validateBinding(key(code)).reason, `digit=${code}`).toBe('modifier-required')
|
|
expect(validateBinding(key(code, { ctrl: true })).valid, `digit=${code}`).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('F1~F24 는 단독으로 통과한다', () => {
|
|
for (let code = VK.F1; code <= VK.F24; code += 1) {
|
|
const result = validateBinding(key(code))
|
|
expect(result.valid, `F${code - VK.F1 + 1}`).toBe(true)
|
|
expect(result.reason, `F${code - VK.F1 + 1}`).toBeNull()
|
|
}
|
|
})
|
|
|
|
it('수정자 키 자체는 단독으로 통과한다', () => {
|
|
const modifierKeys = [
|
|
VK.CtrlLeft,
|
|
VK.CtrlRight,
|
|
VK.AltLeft,
|
|
VK.AltRight,
|
|
VK.ShiftLeft,
|
|
VK.ShiftRight,
|
|
VK.MetaLeft,
|
|
VK.MetaRight
|
|
]
|
|
for (const code of modifierKeys) {
|
|
const result = validateBinding(key(code))
|
|
expect(result.valid, `code=0x${code.toString(16)}`).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('수정자 키에 자기 플래그만 켜도 단독으로 통과한다', () => {
|
|
expect(validateBinding(key(VK.AltRight, { alt: true })).valid).toBe(true)
|
|
})
|
|
|
|
it('카탈로그에 없는 코드는 unknown-key 다', () => {
|
|
const keyboardResult = validateBinding(key(UNKNOWN_VK))
|
|
expect(keyboardResult.valid).toBe(false)
|
|
expect(keyboardResult.reason).toBe('unknown-key')
|
|
expect(keyboardResult.reasonKey).toBe('keybinding.reject.unknownKey')
|
|
|
|
const mouseResult = validateBinding(mouse(UNKNOWN_MOUSE))
|
|
expect(mouseResult.valid).toBe(false)
|
|
expect(mouseResult.reason).toBe('unknown-key')
|
|
})
|
|
})
|
|
|
|
describe('validateBinding — 시스템 예약 조합', () => {
|
|
const reserved: readonly (readonly [string, KeyBinding])[] = [
|
|
['Ctrl+C', key(KEY_C, { ctrl: true })],
|
|
['Ctrl+V', key(KEY_V, { ctrl: true })],
|
|
['Ctrl+X', key(KEY_X, { ctrl: true })],
|
|
['Ctrl+Z', key(KEY_Z, { ctrl: true })],
|
|
['Ctrl+A', key(KEY_A, { ctrl: true })],
|
|
['Ctrl+S', key(KEY_S, { ctrl: true })],
|
|
['Ctrl+W', key(KEY_W, { ctrl: true })],
|
|
['Alt+F4', key(F4, { alt: true })],
|
|
['Alt+Tab', key(VK.Tab, { alt: true })],
|
|
['Ctrl+Alt+Esc', key(VK.Escape, { ctrl: true, alt: true })],
|
|
['Ctrl+Alt+Delete', key(VK.Delete, { ctrl: true, alt: true })]
|
|
]
|
|
|
|
it('예약 조합은 전부 거부된다', () => {
|
|
for (const [name, binding] of reserved) {
|
|
const result = validateBinding(binding)
|
|
expect(result.valid, name).toBe(false)
|
|
expect(result.reason, name).toBe('system-reserved')
|
|
expect(result.reasonKey, name).toBe('keybinding.reject.systemReserved')
|
|
}
|
|
})
|
|
|
|
it('수정자 세팅 순서를 바꿔 만든 동일 예약 조합도 거부된다', () => {
|
|
const ctrlAltDelete: KeyBinding = {
|
|
device: 'keyboard',
|
|
meta: false,
|
|
shift: false,
|
|
alt: true,
|
|
ctrl: true,
|
|
code: VK.Delete
|
|
}
|
|
expect(validateBinding(ctrlAltDelete).reason).toBe('system-reserved')
|
|
|
|
const altCtrlEsc: KeyBinding = {
|
|
device: 'keyboard',
|
|
alt: true,
|
|
meta: false,
|
|
ctrl: true,
|
|
code: VK.Escape,
|
|
shift: false
|
|
}
|
|
expect(validateBinding(altCtrlEsc).reason).toBe('system-reserved')
|
|
|
|
const altTab: KeyBinding = {
|
|
shift: false,
|
|
meta: false,
|
|
alt: true,
|
|
ctrl: false,
|
|
code: VK.Tab,
|
|
device: 'keyboard'
|
|
}
|
|
expect(validateBinding(altTab).reason).toBe('system-reserved')
|
|
})
|
|
|
|
it('예약 조합에 수정자를 더하면 더 이상 예약이 아니다', () => {
|
|
expect(validateBinding(key(KEY_C, { ctrl: true, shift: true })).valid).toBe(true)
|
|
expect(validateBinding(key(KEY_V, { ctrl: true, shift: true })).valid).toBe(true)
|
|
expect(validateBinding(key(F4, { alt: true, ctrl: true })).valid).toBe(true)
|
|
})
|
|
|
|
it('예약 조합에서 수정자를 빼면 예약이 아니다 (다른 사유로 걸린다)', () => {
|
|
const result = validateBinding(key(KEY_C))
|
|
expect(result.reason).toBe('modifier-required')
|
|
expect(validateBinding(key(F4)).valid).toBe(true)
|
|
})
|
|
|
|
it('마우스 좌클릭 거부가 예약 판정보다 먼저다', () => {
|
|
expect(validateBinding(mouse(MouseButton.Left, { ctrl: true })).reason).toBe(
|
|
'device-disabled'
|
|
)
|
|
})
|
|
})
|
|
|
|
// ------------------------------------------------------------
|
|
// detectBindingConflicts
|
|
// ------------------------------------------------------------
|
|
|
|
describe('detectBindingConflicts', () => {
|
|
it('다른 액션이 같은 바인딩을 점유하면 충돌로 잡는다', () => {
|
|
const map = createDefaultBindingMap()
|
|
const shared = key(F9, { ctrl: true })
|
|
map.command = [shared]
|
|
expect(detectBindingConflicts('caption', shared, map).map((c) => c.actionId)).toEqual([
|
|
'command'
|
|
])
|
|
})
|
|
|
|
it('dictation 과 hands-free 는 같은 바인딩을 공유해도 충돌이 아니다', () => {
|
|
const map = createDefaultBindingMap()
|
|
const shared = key(VK.AltRight)
|
|
expect(map.dictation).toEqual([shared])
|
|
expect(map['hands-free']).toEqual([shared])
|
|
expect(detectBindingConflicts('dictation', shared, map)).toEqual([])
|
|
expect(detectBindingConflicts('hands-free', shared, map)).toEqual([])
|
|
})
|
|
|
|
it('자기 자신은 충돌에서 제외된다', () => {
|
|
const map = createDefaultBindingMap()
|
|
expect(detectBindingConflicts('command', key(VK.AltRight, { ctrl: true }), map)).toEqual(
|
|
[]
|
|
)
|
|
})
|
|
|
|
it('수정자 세팅 순서가 달라도 같은 바인딩으로 보고 충돌을 잡는다', () => {
|
|
const map = createDefaultBindingMap()
|
|
map.command = [
|
|
{
|
|
device: 'keyboard',
|
|
shift: true,
|
|
meta: false,
|
|
alt: false,
|
|
ctrl: true,
|
|
code: F9
|
|
}
|
|
]
|
|
const probe: KeyBinding = {
|
|
device: 'keyboard',
|
|
code: F9,
|
|
ctrl: true,
|
|
alt: false,
|
|
shift: true,
|
|
meta: false
|
|
}
|
|
expect(detectBindingConflicts('caption', probe, map).map((c) => c.actionId)).toEqual([
|
|
'command'
|
|
])
|
|
})
|
|
|
|
it('여러 액션이 점유하면 전부 보고한다', () => {
|
|
const map = createDefaultBindingMap()
|
|
const shared = key(F9, { ctrl: true })
|
|
map.command = [shared]
|
|
map['history-popup'] = [shared]
|
|
expect(detectBindingConflicts('caption', shared, map).map((c) => c.actionId)).toEqual([
|
|
'command',
|
|
'history-popup'
|
|
])
|
|
})
|
|
|
|
it('점유 액션의 다중 바인딩 중 하나만 겹쳐도 충돌이다', () => {
|
|
const map = createDefaultBindingMap()
|
|
const shared = key(F9, { ctrl: true })
|
|
map.command = [key(F12), shared]
|
|
expect(detectBindingConflicts('caption', shared, map).map((c) => c.actionId)).toEqual([
|
|
'command'
|
|
])
|
|
})
|
|
|
|
it('겹치지 않으면 빈 배열이다', () => {
|
|
const map = createDefaultBindingMap()
|
|
expect(detectBindingConflicts('caption', key(F12, { meta: true }), map)).toEqual([])
|
|
})
|
|
|
|
it('맵에 없는 액션 키가 있어도 크래시하지 않는다', () => {
|
|
const partial = { dictation: [key(VK.AltRight)] } as unknown as Parameters<
|
|
typeof detectBindingConflicts
|
|
>[2]
|
|
expect(detectBindingConflicts('command', key(F9), partial)).toEqual([])
|
|
})
|
|
})
|
|
|
|
// ------------------------------------------------------------
|
|
// KEY_CATALOG 불변식
|
|
// ------------------------------------------------------------
|
|
|
|
describe('KEY_CATALOG 불변식', () => {
|
|
it('device:code 가 전부 유일하다', () => {
|
|
const seen = new Set<string>()
|
|
const duplicates: string[] = []
|
|
for (const entry of KEY_CATALOG) {
|
|
const id = `${entry.device}:${entry.code}`
|
|
if (seen.has(id)) duplicates.push(id)
|
|
seen.add(id)
|
|
}
|
|
expect(duplicates).toEqual([])
|
|
expect(seen.size).toBe(KEY_CATALOG.length)
|
|
})
|
|
|
|
it('모든 엔트리가 비어있지 않은 label 을 갖는다', () => {
|
|
for (const entry of KEY_CATALOG) {
|
|
expect(entry.label.trim(), `${entry.device}:${entry.code}`).not.toBe('')
|
|
}
|
|
})
|
|
|
|
it('모든 엔트리가 최소 1개의 alias 를 갖는다', () => {
|
|
for (const entry of KEY_CATALOG) {
|
|
expect(entry.aliases.length, `${entry.device}:${entry.code}`).toBeGreaterThanOrEqual(1)
|
|
}
|
|
})
|
|
|
|
it('모든 alias 가 소문자이고 비어있지 않다', () => {
|
|
for (const entry of KEY_CATALOG) {
|
|
for (const alias of entry.aliases) {
|
|
expect(alias, `${entry.device}:${entry.code}`).toBe(alias.toLowerCase())
|
|
expect(alias.trim(), `${entry.device}:${entry.code}`).not.toBe('')
|
|
}
|
|
}
|
|
})
|
|
|
|
it('findKeyCatalogEntry 가 모든 엔트리를 찾고 없는 코드에는 null 을 준다', () => {
|
|
for (const entry of KEY_CATALOG) {
|
|
expect(findKeyCatalogEntry(entry.device, entry.code)).toBe(entry)
|
|
}
|
|
expect(findKeyCatalogEntry('keyboard', UNKNOWN_VK)).toBeNull()
|
|
expect(findKeyCatalogEntry('mouse', UNKNOWN_MOUSE)).toBeNull()
|
|
})
|
|
|
|
it('KEY_CATALOG_GROUP_ORDER 가 실제 등장 그룹을 빠짐없이 중복 없이 담는다', () => {
|
|
const present = new Set<KeyCatalogGroup>(KEY_CATALOG.map((entry) => entry.group))
|
|
expect(new Set(KEY_CATALOG_GROUP_ORDER).size).toBe(KEY_CATALOG_GROUP_ORDER.length)
|
|
expect([...KEY_CATALOG_GROUP_ORDER].sort()).toEqual([...present].sort())
|
|
})
|
|
|
|
it('KEY_CATALOG_GROUP_LABEL_KEYS 가 모든 그룹의 키를 갖는다', () => {
|
|
for (const group of KEY_CATALOG_GROUP_ORDER) {
|
|
expect(KEY_CATALOG_GROUP_LABEL_KEYS[group], group).toBeTruthy()
|
|
}
|
|
expect(Object.keys(KEY_CATALOG_GROUP_LABEL_KEYS).sort()).toEqual(
|
|
[...KEY_CATALOG_GROUP_ORDER].sort()
|
|
)
|
|
})
|
|
|
|
it('마우스 그룹은 5개 버튼 전부를 담는다', () => {
|
|
const mouseCodes = KEY_CATALOG.filter((entry) => entry.device === 'mouse').map(
|
|
(entry) => entry.code
|
|
)
|
|
expect(mouseCodes).toEqual([
|
|
MouseButton.Left,
|
|
MouseButton.Right,
|
|
MouseButton.Middle,
|
|
MouseButton.Back,
|
|
MouseButton.Forward
|
|
])
|
|
})
|
|
|
|
it('카탈로그와 그룹 상수가 동결되어 있다', () => {
|
|
expect(Object.isFrozen(KEY_CATALOG)).toBe(true)
|
|
expect(Object.isFrozen(KEY_CATALOG_GROUP_ORDER)).toBe(true)
|
|
expect(Object.isFrozen(KEY_CATALOG_GROUP_LABEL_KEYS)).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ------------------------------------------------------------
|
|
// KEYBINDING_ACTIONS 불변식
|
|
// ------------------------------------------------------------
|
|
|
|
describe('KEYBINDING_ACTIONS 불변식', () => {
|
|
it('액션 id 가 유일하다', () => {
|
|
const ids = KEYBINDING_ACTIONS.map((action) => action.id)
|
|
expect(new Set(ids).size).toBe(ids.length)
|
|
})
|
|
|
|
it('모든 액션이 i18n 키와 기본 바인딩을 갖는다', () => {
|
|
for (const action of KEYBINDING_ACTIONS) {
|
|
expect(action.labelKey.trim(), action.id).not.toBe('')
|
|
expect(action.descriptionKey.trim(), action.id).not.toBe('')
|
|
expect(action.defaultBindings.length, action.id).toBeGreaterThan(0)
|
|
}
|
|
})
|
|
|
|
it('모든 액션의 기본 바인딩이 validateBinding 을 통과한다', () => {
|
|
for (const action of KEYBINDING_ACTIONS) {
|
|
for (const binding of action.defaultBindings) {
|
|
const result = validateBinding(binding)
|
|
expect(
|
|
result.valid,
|
|
`${action.id} → ${JSON.stringify(binding)} (reason=${result.reason ?? 'null'})`
|
|
).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('findActionSpec / isKeyBindingActionId 가 카탈로그와 일치한다', () => {
|
|
for (const action of KEYBINDING_ACTIONS) {
|
|
expect(findActionSpec(action.id)).toBe(action)
|
|
expect(isKeyBindingActionId(action.id)).toBe(true)
|
|
}
|
|
expect(findActionSpec('nope' as KeyBindingActionId)).toBeNull()
|
|
expect(isKeyBindingActionId('nope')).toBe(false)
|
|
})
|
|
|
|
it('createDefaultBindingMap 이 모든 액션 키를 갖는다', () => {
|
|
const map = createDefaultBindingMap()
|
|
expect(Object.keys(map).sort()).toEqual(KEYBINDING_ACTIONS.map((a) => a.id).sort())
|
|
for (const action of KEYBINDING_ACTIONS) {
|
|
expect(map[action.id]).toEqual([...action.defaultBindings])
|
|
}
|
|
})
|
|
|
|
it('createDefaultBindingMap 반환값을 변형해도 다음 호출에 영향이 없다', () => {
|
|
const first = createDefaultBindingMap()
|
|
first.dictation.push(key(F12))
|
|
first.command[0].ctrl = false
|
|
first.command[0].code = UNKNOWN_VK
|
|
|
|
const second = createDefaultBindingMap()
|
|
expect(second.dictation).toHaveLength(1)
|
|
expect(second.command[0].ctrl).toBe(true)
|
|
expect(second.command[0].code).toBe(VK.AltRight)
|
|
})
|
|
|
|
it('createDefaultBindingMap 이 액션 스펙의 defaultBindings 를 공유하지 않는다', () => {
|
|
const map = createDefaultBindingMap()
|
|
map.dictation[0].shift = true
|
|
const spec = findActionSpec('dictation')
|
|
expect(spec).not.toBeNull()
|
|
if (spec !== null) expect(spec.defaultBindings[0].shift).toBe(false)
|
|
})
|
|
|
|
it('기본 바인딩끼리 검출되는 충돌이 없다', () => {
|
|
const map = createDefaultBindingMap()
|
|
const found: string[] = []
|
|
for (const action of KEYBINDING_ACTIONS) {
|
|
for (const binding of map[action.id]) {
|
|
for (const conflict of detectBindingConflicts(action.id, binding, map)) {
|
|
found.push(`${action.id} <-> ${conflict.actionId}`)
|
|
}
|
|
}
|
|
}
|
|
expect(found).toEqual([])
|
|
})
|
|
|
|
it('dictation 과 hands-free 만 doublePress 로 구분되는 같은 기본 바인딩을 쓴다', () => {
|
|
const dictation = findActionSpec('dictation')
|
|
const handsFree = findActionSpec('hands-free')
|
|
expect(dictation).not.toBeNull()
|
|
expect(handsFree).not.toBeNull()
|
|
if (dictation === null || handsFree === null) return
|
|
expect(bindingKey(dictation.defaultBindings[0])).toBe(
|
|
bindingKey(handsFree.defaultBindings[0])
|
|
)
|
|
expect(dictation.doublePress).toBe(false)
|
|
expect(handsFree.doublePress).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ------------------------------------------------------------
|
|
// searchKeyCatalog
|
|
// ------------------------------------------------------------
|
|
|
|
describe('searchKeyCatalog', () => {
|
|
it('빈 문자열과 공백은 전체를 반환한다', () => {
|
|
expect(searchKeyCatalog('')).toHaveLength(KEY_CATALOG.length)
|
|
expect(searchKeyCatalog(' ')).toHaveLength(KEY_CATALOG.length)
|
|
expect(searchKeyCatalog('\t\n ')).toHaveLength(KEY_CATALOG.length)
|
|
})
|
|
|
|
it('대소문자를 무시한다', () => {
|
|
expect(searchKeyCatalog('ALTGR')).toEqual(searchKeyCatalog('altgr'))
|
|
expect(searchKeyCatalog('EsC')).toEqual(searchKeyCatalog('esc'))
|
|
expect(searchKeyCatalog('MB4')).toEqual(searchKeyCatalog('mb4'))
|
|
})
|
|
|
|
it('앞뒤 공백을 무시한다', () => {
|
|
expect(searchKeyCatalog(' altgr ')).toEqual(searchKeyCatalog('altgr'))
|
|
})
|
|
|
|
it('alias 로 검색된다 — mb4 는 Mouse Back', () => {
|
|
const hits = searchKeyCatalog('mb4')
|
|
expect(hits).toHaveLength(1)
|
|
expect(hits[0].device).toBe('mouse')
|
|
expect(hits[0].code).toBe(MouseButton.Back)
|
|
})
|
|
|
|
it('alias 로 검색된다 — altgr 은 Right Alt', () => {
|
|
const hits = searchKeyCatalog('altgr')
|
|
expect(hits).toHaveLength(1)
|
|
expect(hits[0].device).toBe('keyboard')
|
|
expect(hits[0].code).toBe(VK.AltRight)
|
|
})
|
|
|
|
it('alias 로 검색된다 — esc 는 Escape', () => {
|
|
const hits = searchKeyCatalog('esc')
|
|
expect(hits).toHaveLength(1)
|
|
expect(hits[0].code).toBe(VK.Escape)
|
|
})
|
|
|
|
it('label 로도 검색된다', () => {
|
|
const hits = searchKeyCatalog('numlock')
|
|
expect(hits.map((entry) => entry.code)).toContain(VK.NumLock)
|
|
})
|
|
|
|
it('공유 alias 는 여러 엔트리를 반환한다', () => {
|
|
const hits = searchKeyCatalog('option')
|
|
expect(hits.map((entry) => entry.code).sort()).toEqual([VK.AltLeft, VK.AltRight].sort())
|
|
})
|
|
|
|
it('localizedLabels 로 넘긴 번역명으로도 매칭된다', () => {
|
|
const localized: Record<string, string> = {
|
|
[`mouse:${MouseButton.Forward}`]: '마우스 앞으로 버튼'
|
|
}
|
|
const hits = searchKeyCatalog('앞으로', localized)
|
|
expect(hits).toHaveLength(1)
|
|
expect(hits[0].code).toBe(MouseButton.Forward)
|
|
})
|
|
|
|
it('localizedLabels 가 없으면 번역명으로는 매칭되지 않는다', () => {
|
|
expect(searchKeyCatalog('앞으로')).toEqual([])
|
|
})
|
|
|
|
it('매칭이 없으면 빈 배열을 반환한다', () => {
|
|
expect(searchKeyCatalog('존재하지않는키이름')).toEqual([])
|
|
})
|
|
|
|
it('반환 배열을 변형해도 KEY_CATALOG 는 그대로다', () => {
|
|
const all = searchKeyCatalog('')
|
|
all.pop()
|
|
expect(searchKeyCatalog('')).toHaveLength(KEY_CATALOG.length)
|
|
})
|
|
})
|
|
|
|
// ------------------------------------------------------------
|
|
// 역직렬화 가드
|
|
// ------------------------------------------------------------
|
|
|
|
describe('isKeyBinding', () => {
|
|
it('올바른 바인딩을 통과시킨다', () => {
|
|
expect(isKeyBinding(key(KEY_A, { ctrl: true }))).toBe(true)
|
|
expect(isKeyBinding(mouse(MouseButton.Back))).toBe(true)
|
|
})
|
|
|
|
it('레코드가 아니면 거부한다', () => {
|
|
for (const value of [null, undefined, 42, 'x', true]) {
|
|
expect(isKeyBinding(value), JSON.stringify(value ?? null)).toBe(false)
|
|
}
|
|
})
|
|
|
|
it('device 가 잘못되면 거부한다', () => {
|
|
expect(
|
|
isKeyBinding({ device: 'joystick', code: 1, ctrl: false, alt: false, shift: false, meta: false })
|
|
).toBe(false)
|
|
})
|
|
|
|
it('code 타입이 잘못되거나 유한하지 않으면 거부한다', () => {
|
|
expect(
|
|
isKeyBinding({ device: 'keyboard', code: '65', ctrl: false, alt: false, shift: false, meta: false })
|
|
).toBe(false)
|
|
expect(
|
|
isKeyBinding({ device: 'keyboard', code: Number.NaN, ctrl: false, alt: false, shift: false, meta: false })
|
|
).toBe(false)
|
|
expect(
|
|
isKeyBinding({ device: 'keyboard', code: Number.POSITIVE_INFINITY, ctrl: false, alt: false, shift: false, meta: false })
|
|
).toBe(false)
|
|
})
|
|
|
|
it('수정자 필드가 누락되거나 타입이 다르면 거부한다', () => {
|
|
expect(isKeyBinding({ device: 'keyboard', code: 65, ctrl: true, alt: false, shift: false })).toBe(false)
|
|
expect(
|
|
isKeyBinding({ device: 'keyboard', code: 65, ctrl: 'yes', alt: false, shift: false, meta: false })
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('parseBindingList', () => {
|
|
it('배열이 아니면 빈 배열을 반환한다', () => {
|
|
expect(parseBindingList(null)).toEqual([])
|
|
expect(parseBindingList(undefined)).toEqual([])
|
|
expect(parseBindingList('k:165:')).toEqual([])
|
|
expect(parseBindingList({ device: 'keyboard', code: 65 })).toEqual([])
|
|
expect(parseBindingList(7)).toEqual([])
|
|
})
|
|
|
|
it('손상된 항목을 걸러낸다', () => {
|
|
const stored = [
|
|
{ device: 'keyboard', code: 65, ctrl: true, alt: false, shift: false },
|
|
{ device: 'keyboard', code: '65', ctrl: true, alt: false, shift: false, meta: false },
|
|
{ device: 'joystick', code: 1, ctrl: false, alt: false, shift: false, meta: false },
|
|
{ device: 'keyboard', code: Number.NaN, ctrl: false, alt: false, shift: false, meta: false },
|
|
null,
|
|
'nope',
|
|
key(F5, { ctrl: true })
|
|
]
|
|
const parsed = parseBindingList(stored)
|
|
expect(parsed).toHaveLength(1)
|
|
expect(parsed[0].code).toBe(F5)
|
|
expect(parsed[0].ctrl).toBe(true)
|
|
})
|
|
|
|
it('중복 바인딩을 제거한다', () => {
|
|
const stored = [
|
|
key(F5, { ctrl: true }),
|
|
{ device: 'keyboard', shift: false, meta: false, alt: false, ctrl: true, code: F5 },
|
|
key(F6)
|
|
]
|
|
const parsed = parseBindingList(stored)
|
|
expect(parsed).toHaveLength(2)
|
|
expect(parsed.map((b) => b.code)).toEqual([F5, F6])
|
|
})
|
|
|
|
it('정규화 후 같아지는 항목도 중복으로 제거한다', () => {
|
|
const parsed = parseBindingList([key(VK.AltRight, { alt: true }), key(VK.AltRight)])
|
|
expect(parsed).toHaveLength(1)
|
|
})
|
|
|
|
it('저장값을 정규화해서 돌려준다', () => {
|
|
const parsed = parseBindingList([key(VK.AltRight, { alt: true, ctrl: true })])
|
|
expect(parsed).toHaveLength(1)
|
|
expect(parsed[0].alt).toBe(false)
|
|
expect(parsed[0].ctrl).toBe(true)
|
|
})
|
|
|
|
it('빈 배열은 빈 배열이다', () => {
|
|
expect(parseBindingList([])).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('parseBindingMap', () => {
|
|
it('레코드가 아니면 전체 기본값을 반환한다', () => {
|
|
expect(parseBindingMap(null)).toEqual(createDefaultBindingMap())
|
|
expect(parseBindingMap('x')).toEqual(createDefaultBindingMap())
|
|
expect(parseBindingMap(undefined)).toEqual(createDefaultBindingMap())
|
|
})
|
|
|
|
it('누락·손상 액션을 기본값으로 채운다', () => {
|
|
const defaults = createDefaultBindingMap()
|
|
const parsed = parseBindingMap({
|
|
dictation: [key(F8)],
|
|
command: 'garbage',
|
|
caption: [],
|
|
'history-popup': [{ device: 'keyboard', code: 65 }]
|
|
})
|
|
expect(parsed.dictation).toEqual([key(F8)])
|
|
expect(parsed.command).toEqual(defaults.command)
|
|
expect(parsed.caption).toEqual(defaults.caption)
|
|
expect(parsed['history-popup']).toEqual(defaults['history-popup'])
|
|
expect(parsed['command-popup']).toEqual(defaults['command-popup'])
|
|
expect(parsed['hands-free']).toEqual(defaults['hands-free'])
|
|
})
|
|
|
|
it('모든 액션 키를 갖는다', () => {
|
|
const parsed = parseBindingMap({ dictation: [key(F8)] })
|
|
expect(Object.keys(parsed).sort()).toEqual(KEYBINDING_ACTIONS.map((a) => a.id).sort())
|
|
})
|
|
|
|
it('알 수 없는 액션 키는 무시한다', () => {
|
|
const parsed = parseBindingMap({ 'not-an-action': [key(F8)] })
|
|
expect(parsed).toEqual(createDefaultBindingMap())
|
|
expect(Object.keys(parsed)).not.toContain('not-an-action')
|
|
})
|
|
|
|
it('저장된 값을 정규화해서 돌려준다', () => {
|
|
const parsed = parseBindingMap({
|
|
dictation: [key(VK.AltRight, { alt: true, ctrl: true })]
|
|
})
|
|
expect(parsed.dictation).toEqual([key(VK.AltRight, { ctrl: true })])
|
|
})
|
|
|
|
it('한 액션의 중복 바인딩을 제거한다', () => {
|
|
const parsed = parseBindingMap({ dictation: [key(F8), key(F8)] })
|
|
expect(parsed.dictation).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
// ------------------------------------------------------------
|
|
// 표시 라벨
|
|
// ------------------------------------------------------------
|
|
|
|
describe('formatBindingSegments / joinBindingSegments', () => {
|
|
it('null 이면 빈 배열이다', () => {
|
|
expect(formatBindingSegments(null, 'win32')).toEqual([])
|
|
expect(formatBindingSegments(null, 'darwin')).toEqual([])
|
|
})
|
|
|
|
it('darwin 은 수정자를 ⌃⌥⇧⌘ 순서로 낸다', () => {
|
|
const binding = key(VK.F1, { meta: true, shift: true, alt: true, ctrl: true })
|
|
expect(labelsOf(binding, 'darwin')).toEqual(['⌃', '⌥', '⇧', '⌘', 'F1'])
|
|
})
|
|
|
|
it('win32 는 수정자를 Ctrl Win Alt Shift 순서로 낸다', () => {
|
|
const binding = key(VK.F1, { meta: true, shift: true, alt: true, ctrl: true })
|
|
expect(labelsOf(binding, 'win32')).toEqual(['Ctrl', 'Win', 'Alt', 'Shift', 'F1'])
|
|
})
|
|
|
|
it('linux 는 win32 와 같은 표기를 쓴다', () => {
|
|
const binding = key(VK.F1, { meta: true, shift: true, alt: true, ctrl: true })
|
|
expect(labelsOf(binding, 'linux')).toEqual(labelsOf(binding, 'win32'))
|
|
})
|
|
|
|
it('수정자 세팅 순서와 무관하게 같은 표기를 낸다', () => {
|
|
const a: KeyBinding = {
|
|
device: 'keyboard',
|
|
code: KEY_A,
|
|
ctrl: true,
|
|
shift: true,
|
|
alt: false,
|
|
meta: false
|
|
}
|
|
const b: KeyBinding = {
|
|
shift: true,
|
|
device: 'keyboard',
|
|
meta: false,
|
|
ctrl: true,
|
|
alt: false,
|
|
code: KEY_A
|
|
}
|
|
expect(labelsOf(a, 'win32')).toEqual(labelsOf(b, 'win32'))
|
|
})
|
|
|
|
it('darwin 은 구분자 없이 결합한다', () => {
|
|
const segments = labelsOf(key(KEY_A, { ctrl: true, shift: true }), 'darwin')
|
|
expect(segments).toEqual(['⌃', '⇧', 'A'])
|
|
expect(joinBindingSegments(segments, 'darwin')).toBe('⌃⇧A')
|
|
})
|
|
|
|
it('win32 는 " + " 로 결합한다', () => {
|
|
const segments = labelsOf(key(KEY_A, { ctrl: true, shift: true }), 'win32')
|
|
expect(segments).toEqual(['Ctrl', 'Shift', 'A'])
|
|
expect(joinBindingSegments(segments, 'win32')).toBe('Ctrl + Shift + A')
|
|
expect(joinBindingSegments(segments, 'linux')).toBe('Ctrl + Shift + A')
|
|
})
|
|
|
|
it('세그먼트가 없으면 빈 문자열이다', () => {
|
|
expect(joinBindingSegments([], 'win32')).toBe('')
|
|
expect(joinBindingSegments([], 'darwin')).toBe('')
|
|
})
|
|
|
|
it('macLabel 이 있는 엔트리는 darwin 에서 그것을 쓴다', () => {
|
|
expect(labelsOf(key(VK.AltRight), 'darwin')).toEqual(['Right ⌥'])
|
|
expect(labelsOf(key(VK.AltRight), 'win32')).toEqual(['Right Alt'])
|
|
expect(labelsOf(key(VK.Delete, { ctrl: true }), 'darwin')).toEqual(['⌃', '⌦'])
|
|
expect(labelsOf(key(VK.Delete, { ctrl: true }), 'win32')).toEqual(['Ctrl', 'Delete'])
|
|
})
|
|
|
|
it('macLabel 이 없으면 darwin 에서도 label 을 쓴다', () => {
|
|
expect(labelsOf(key(VK.Home, { ctrl: true }), 'darwin')).toEqual(['⌃', 'Home'])
|
|
})
|
|
|
|
it('labelKey 가 있는 엔트리는 i18nKey 를 함께 낸다', () => {
|
|
const segments = formatBindingSegments(mouse(MouseButton.Back), 'win32')
|
|
expect(segments).toHaveLength(1)
|
|
expect(segments[0].label).toBe('Mouse Back')
|
|
expect(segments[0].i18nKey).toBe('keybinding.mouse.back')
|
|
})
|
|
|
|
it('labelKey 가 없는 엔트리는 i18nKey 를 담지 않는다', () => {
|
|
const segments = formatBindingSegments(key(KEY_A, { ctrl: true }), 'win32')
|
|
expect(segments[segments.length - 1].i18nKey).toBeUndefined()
|
|
})
|
|
|
|
it('주 키가 수정자 자체이면 중복 표기하지 않는다', () => {
|
|
expect(labelsOf(key(VK.AltRight, { alt: true }), 'win32')).toEqual(['Right Alt'])
|
|
expect(labelsOf(key(VK.CtrlLeft, { ctrl: true }), 'darwin')).toEqual(['Left ⌃'])
|
|
expect(labelsOf(key(VK.MetaLeft, { meta: true, ctrl: true }), 'win32')).toEqual([
|
|
'Ctrl',
|
|
'Left Win'
|
|
])
|
|
})
|
|
|
|
it('카탈로그에 없는 코드도 크래시 없이 폴백 라벨을 낸다', () => {
|
|
expect(labelsOf(key(UNKNOWN_VK), 'win32')).toEqual([`Key${UNKNOWN_VK}`])
|
|
expect(labelsOf(key(UNKNOWN_VK), 'darwin')).toEqual([`Key${UNKNOWN_VK}`])
|
|
expect(labelsOf(mouse(UNKNOWN_MOUSE), 'win32')).toEqual([`Mouse${UNKNOWN_MOUSE}`])
|
|
expect(labelsOf(key(UNKNOWN_VK, { ctrl: true }), 'win32')).toEqual([
|
|
'Ctrl',
|
|
`Key${UNKNOWN_VK}`
|
|
])
|
|
})
|
|
|
|
it('카탈로그의 모든 엔트리가 두 플랫폼에서 라벨을 낸다', () => {
|
|
const platforms: readonly BindingPlatform[] = ['darwin', 'win32', 'linux']
|
|
for (const entry of KEY_CATALOG) {
|
|
for (const platform of platforms) {
|
|
const segments = formatBindingSegments(
|
|
{ device: entry.device, code: entry.code, ctrl: false, alt: false, shift: false, meta: false },
|
|
platform
|
|
)
|
|
expect(segments.length, `${entry.device}:${entry.code}`).toBeGreaterThanOrEqual(1)
|
|
expect(segments[segments.length - 1].label.trim(), `${entry.device}:${entry.code}`).not.toBe('')
|
|
}
|
|
}
|
|
})
|
|
})
|