Files
opsxshell/test/i18n/localeKeys.test.ts
T

123 lines
4.3 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { I18N_NAMESPACES } from '../../src/i18n'
import { APP_LANGUAGES, type AppLanguage } from '../../src/i18n/detectLocale'
function flattenKeys(value: unknown, prefix = ''): string[] {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return prefix ? [prefix] : []
}
const entries = Object.entries(value as Record<string, unknown>)
if (entries.length === 0) return prefix ? [prefix] : []
return entries.flatMap(([key, nested]) => {
const next = prefix ? `${prefix}.${key}` : key
if (nested !== null && typeof nested === 'object' && !Array.isArray(nested)) {
const childKeys = flattenKeys(nested, next)
return childKeys.length > 0 ? childKeys : [next]
}
return [next]
})
}
async function loadLocale(namespace: string, language: AppLanguage) {
const mod = await import(`../../src/i18n/locales/${language}/${namespace}.json`)
return mod.default as Record<string, unknown>
}
describe('i18n locale key parity', () => {
for (const namespace of I18N_NAMESPACES) {
for (const language of APP_LANGUAGES) {
it(`${language} keys match en for ${namespace}`, async () => {
const [locale, en] = await Promise.all([loadLocale(namespace, language), loadLocale(namespace, 'en')])
const localeKeys = flattenKeys(locale).sort()
const enKeys = flattenKeys(en).sort()
expect(localeKeys).toEqual(enKeys)
for (const key of localeKeys) {
const value = key.split('.').reduce<unknown>((node, part) => {
if (node && typeof node === 'object' && part in node) {
return (node as Record<string, unknown>)[part]
}
return undefined
}, locale)
if (typeof value === 'string') {
expect(value.trim().length, `${language}:${namespace}.${key}`).toBeGreaterThan(0)
}
}
})
}
}
})
function flattenValues(value: unknown, prefix = ''): Record<string, string> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return prefix && typeof value === 'string' ? { [prefix]: value } : {}
}
const out: Record<string, string> = {}
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
const next = prefix ? `${prefix}.${key}` : key
if (nested !== null && typeof nested === 'object' && !Array.isArray(nested)) {
Object.assign(out, flattenValues(nested, next))
} else if (typeof nested === 'string') {
out[next] = nested
}
}
return out
}
/** Product names, URLs, and format tokens that stay the same in every locale. */
const SETTINGS_ALLOW_SAME_AS_EN = new Set([
'language.zhCN',
'language.zhTW',
'language.en',
'language.ja',
'language.ko',
'language.vi',
'language.ru',
'nav.ssh',
'about.appVersion',
'about.githubDesc',
'about.license',
'about.licenseLinkDesc',
'about.wechat',
])
describe('settings copy is translated', () => {
it.each(APP_LANGUAGES.filter((language) => language !== 'en'))(
'%s does not leave English settings/about strings',
async (language) => {
const [locale, en] = await Promise.all([loadLocale('settings', language), loadLocale('settings', 'en')])
const localeValues = flattenValues(locale)
const enValues = flattenValues(en)
const leftover = Object.keys(enValues).filter((key) => {
if (SETTINGS_ALLOW_SAME_AS_EN.has(key)) return false
return localeValues[key] === enValues[key]
})
expect(leftover, leftover.join(', ')).toEqual([])
}
)
})
/** Product name and shared layout templates in Agent CLI. */
const AI_ALLOW_SAME_AS_EN = new Set(['panel.title', 'panel.modelConfigured', 'panel.sshReady', 'step.confirmMessage'])
describe('ai copy is translated', () => {
it.each(APP_LANGUAGES.filter((language) => language !== 'en'))(
'%s does not leave English Agent CLI strings',
async (language) => {
const [locale, en] = await Promise.all([loadLocale('ai', language), loadLocale('ai', 'en')])
const localeValues = flattenValues(locale)
const enValues = flattenValues(en)
const leftover = Object.keys(enValues).filter((key) => {
if (AI_ALLOW_SAME_AS_EN.has(key)) return false
return localeValues[key] === enValues[key]
})
expect(leftover, leftover.join(', ')).toEqual([])
}
)
})