// 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> 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(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() 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() 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(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 = { [`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('') } } }) })