import { describe, expect, it } from 'vitest' // 直接 import 未 merge 的裸 locale 对象。 // // 刻意**不**用 `@/i18n/messages` 的 messages:那里 zh 已经和 en 做过 // mergeMessagesWithFallback(见 packages/client/src/i18n/messages.ts), // zh 缺的 key 会自动从 en 补上,于是「zh 缺 key」这件事在运行时永远看不出来。 // 本文件要卡的是**源文件层面**的对等,所以必须用裸对象。 import en from '@/i18n/locales/en' import zh from '@/i18n/locales/zh' type Messages = Record /** * 关于 tests/client/i18n-coverage.test.ts 里的 * `ALLOWED_MISSING_KEYS = { 'changelog.new_0_5_4_7', 'chat.sessionNotFound' }`: * * 实测结论(Phase 008 §3.1 逐 key 核对 en.ts / zh.ts 原文): * - `chat.sessionNotFound` —— **en 与 zh 都有**(en.ts:665 / zh.ts:665, * 分别在各自 `chat` 命名空间下)。它出现在 i18n-coverage 的豁免表里属于 * 历史遗留/过宽豁免,对本测试**不适用**,因此这里不做任何豁免。 * - `changelog.new_0_5_4_7` —— **en 与 zh 都没有**。它是 changelog 的版本条目名 * (packages/client/src/data/changelog.ts 的数据),从来不是 locale key; * i18n-coverage 把 changelog 的 change 文案也当成 key 去查 en,才会需要豁免。 * 两个 locale 同时缺失,不构成「单边缺 key」,因此这里同样不需要豁免。 * * 换句话说:这两个 key 在本测试里**都不是**单边差异,豁免它们只会把真实缺陷 * 藏起来。若将来真的出现单边差异,正确做法是补齐缺失一侧的 locale 文件, * 而不是往这里加豁免。 */ type LeafKind = 'string' | 'object' | 'other' /** 递归 flatten:返回 完整 key 路径 -> 叶子类型(命名空间本身也记为 'object') */ function flatten(source: Messages, prefix = '', out = new Map()) { for (const [key, value] of Object.entries(source)) { const fullKey = prefix ? `${prefix}.${key}` : key if (value !== null && typeof value === 'object' && !Array.isArray(value)) { out.set(fullKey, 'object') flatten(value as Messages, fullKey, out) } else { out.set(fullKey, typeof value === 'string' ? 'string' : 'other') } } return out } const enKeys = flatten(en as Messages) const zhKeys = flatten(zh as Messages) const missingInZh = [...enKeys.keys()].filter(key => !zhKeys.has(key)).sort() const missingInEn = [...zhKeys.keys()].filter(key => !enKeys.has(key)).sort() const typeMismatches = [...enKeys.keys()] .filter(key => zhKeys.has(key) && zhKeys.get(key) !== enKeys.get(key)) .sort() .map(key => `${key}: en=${enKeys.get(key)} / zh=${zhKeys.get(key)}`) describe('i18n locale parity between zh and en', () => { it('flattens a non-trivial key space from both locales', () => { // 防止 import 路径写错、或 flatten 逻辑改坏导致两边都变成空集合、永远绿灯 expect(enKeys.size).toBeGreaterThan(1000) expect(zhKeys.size).toBeGreaterThan(1000) // 抽查几个嵌套命名空间,确认递归真的展开了层级 for (const key of ['chat.sessionNotFound', 'chat.renameSession']) { expect(enKeys.get(key), `en 缺少嵌套 key ${key}`).toBe('string') expect(zhKeys.get(key), `zh 缺少嵌套 key ${key}`).toBe('string') } }) it('defines every English key in Chinese', () => { expect( missingInZh, missingInZh.length === 0 ? '' : [ `${missingInZh.length} 个 key 在 en.ts 里有、zh.ts 里没有:`, ...missingInZh.map(key => ` ${key}`), '', '修复方式:在 packages/client/src/i18n/locales/zh.ts 对应的命名空间下补上中文。', '注意不要改本文件来豁免它们——单边缺失会让线上退化成英文文案。', ].join('\n'), ).toEqual([]) }) it('defines every Chinese key in English', () => { expect( missingInEn, missingInEn.length === 0 ? '' : [ `${missingInEn.length} 个 key 在 zh.ts 里有、en.ts 里没有:`, ...missingInEn.map(key => ` ${key}`), '', '修复方式:在 packages/client/src/i18n/locales/en.ts 对应的命名空间下补上英文。', '英文是 fallback 语言,缺失会让中文用户在切换语言时看到空白。', ].join('\n'), ).toEqual([]) }) it('keeps the leaf type of every shared key consistent', () => { // 例如 en 里某个 key 是命名空间对象、zh 里却是字符串:运行时会在插值时炸掉, // 或者某一侧多出一层嵌套导致 t('a.b') 查不到值。 expect( typeMismatches, typeMismatches.length === 0 ? '' : [ '以下 key 在 en 与 zh 的叶子类型不一致(string / object / other):', ...typeMismatches.map(entry => ` ${entry}`), '', '修复方式:把两侧的层级结构改成一致(要么都是字符串,要么都是同层命名空间)。', ].join('\n'), ).toEqual([]) }) })