// 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 { 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 = {} 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 { const keys = new Set() 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> /** 거부·경고 경로를 폭넓게 태우기 위한 수정자 조합. */ 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 warningKeys: Set reasons: Set } /** * 카탈로그 전 엔트리 × 수정자 조합 + 카탈로그 밖 코드를 실제로 validateBinding 에 태워 * 반환된 i18n 키를 수집한다. * * 하드코딩 목록을 쓰지 않는 이유: 거부 사유나 카탈로그 항목이 늘어나면 그 키가 자동으로 * 수집 대상에 들어와야 한다. */ function sweepValidation(): ValidationSweep { const sweep: ValidationSweep = { reasonKeys: new Set(), warningKeys: new Set(), reasons: new Set() } 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([ ...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([]) }) })