mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
226 lines
8.0 KiB
JavaScript
226 lines
8.0 KiB
JavaScript
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import { main as verifyMobileCatalog } from './verify-mobile-localization-catalog.mjs'
|
|
|
|
const LOCALES = ['en', 'es', 'ja', 'ko', 'zh']
|
|
const NATIVE_LOCALES = ['en', 'es', 'ja', 'ko', 'zh-Hans']
|
|
const NATIVE_CATALOG = {
|
|
ios: {
|
|
CFBundleDisplayName: 'Orca',
|
|
NSCameraUsageDescription: 'Camera fallback',
|
|
NSLocalNetworkUsageDescription: 'Network fallback',
|
|
NSMicrophoneUsageDescription: 'Microphone fallback',
|
|
NSPhotoLibraryUsageDescription: 'Photo fallback'
|
|
},
|
|
android: { app_name: 'Orca' }
|
|
}
|
|
|
|
function defaultAppConfig() {
|
|
return {
|
|
expo: {
|
|
name: 'Orca',
|
|
locales: Object.fromEntries(
|
|
NATIVE_LOCALES.map((locale) => [locale, `./locales/${locale}.json`])
|
|
),
|
|
ios: {
|
|
infoPlist: {
|
|
NSLocalNetworkUsageDescription: 'Network fallback',
|
|
NSMicrophoneUsageDescription: 'Microphone fallback',
|
|
NSPhotoLibraryUsageDescription: 'Photo fallback'
|
|
}
|
|
},
|
|
plugins: [
|
|
['expo-localization', { supportedLocales: NATIVE_LOCALES }],
|
|
[
|
|
'expo-camera',
|
|
{ cameraPermission: 'Camera fallback', microphonePermission: 'Microphone fallback' }
|
|
],
|
|
['expo-image-picker', { photosPermission: 'Photo fallback' }]
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
function writeJson(filePath, value) {
|
|
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
}
|
|
|
|
function makeProject({ sourceText, catalogs, nativeCatalogs, appConfig }) {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'orca-mobile-localization-'))
|
|
const appDirectory = path.join(root, 'mobile', 'app')
|
|
const sourceDirectory = path.join(root, 'mobile', 'src')
|
|
const localeDirectory = path.join(sourceDirectory, 'i18n', 'locales')
|
|
const nativeLocaleDirectory = path.join(root, 'mobile', 'locales')
|
|
mkdirSync(appDirectory, { recursive: true })
|
|
mkdirSync(localeDirectory, { recursive: true })
|
|
mkdirSync(nativeLocaleDirectory, { recursive: true })
|
|
writeFileSync(path.join(appDirectory, 'Example.tsx'), sourceText, 'utf8')
|
|
writeJson(path.join(root, 'mobile', 'app.json'), appConfig ?? defaultAppConfig())
|
|
|
|
for (const locale of LOCALES) {
|
|
writeJson(path.join(localeDirectory, `${locale}.json`), catalogs?.[locale] ?? catalogs.en)
|
|
}
|
|
for (const locale of NATIVE_LOCALES) {
|
|
writeJson(
|
|
path.join(nativeLocaleDirectory, `${locale}.json`),
|
|
nativeCatalogs?.[locale] ?? NATIVE_CATALOG
|
|
)
|
|
}
|
|
return root
|
|
}
|
|
|
|
async function runFailedVerification(root) {
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
try {
|
|
await expect(verifyMobileCatalog(root)).resolves.toBe(1)
|
|
return error.mock.calls.flat().join('\n')
|
|
} finally {
|
|
error.mockRestore()
|
|
}
|
|
}
|
|
|
|
describe('verify-mobile-localization-catalog', () => {
|
|
it('verifies literal and conditional keys with matching options', async () => {
|
|
const catalog = { m: { greeting: 'Hello {{name}}', farewell: 'Bye {{name}}' } }
|
|
const root = makeProject({
|
|
sourceText:
|
|
"import { t } from '@/i18n/mobile-i18n'\nconst name = 'Orca'\nexport const label = t(name ? 'm.greeting' : 'm.farewell', { name })\n",
|
|
catalogs: { en: catalog }
|
|
})
|
|
|
|
await expect(verifyMobileCatalog(root)).resolves.toBe(0)
|
|
})
|
|
|
|
it('reports missing keys in conditional branches', async () => {
|
|
const root = makeProject({
|
|
sourceText:
|
|
"import { t } from '@/i18n/mobile-i18n'\nexport const label = t(flag ? 'm.known' : 'm.missing')\n",
|
|
catalogs: { en: { m: { known: 'Known' } } }
|
|
})
|
|
|
|
expect(await runFailedVerification(root)).toContain('missing English key: m.missing')
|
|
})
|
|
|
|
it('rejects translation keys that cannot be statically inspected', async () => {
|
|
const root = makeProject({
|
|
sourceText: "import { t } from '@/i18n/mobile-i18n'\nexport const label = t(runtimeKey)\n",
|
|
catalogs: { en: { m: { known: 'Known' } } }
|
|
})
|
|
|
|
expect(await runFailedVerification(root)).toContain('not statically inspectable')
|
|
})
|
|
|
|
it('requires call options to exactly match English placeholders', async () => {
|
|
const root = makeProject({
|
|
sourceText:
|
|
"import { t } from '@/i18n/mobile-i18n'\nexport const label = t('m.greeting', { value: 'Orca' })\n",
|
|
catalogs: { en: { m: { greeting: 'Hello {{name}}' } } }
|
|
})
|
|
|
|
const report = await runFailedVerification(root)
|
|
expect(report).toContain('options [value]')
|
|
expect(report).toContain('placeholders [name]')
|
|
})
|
|
|
|
it('allows missing translations in sparse locale catalogs', async () => {
|
|
const en = { m: { greeting: 'Hello {{name}}', farewell: 'Bye' } }
|
|
const root = makeProject({
|
|
sourceText:
|
|
"import { t } from '@/i18n/mobile-i18n'\nexport const label = t('m.greeting', { name: 'Orca' })\n",
|
|
catalogs: {
|
|
en,
|
|
es: { m: { greeting: 'Hola {{name}}' } }
|
|
}
|
|
})
|
|
|
|
await expect(verifyMobileCatalog(root)).resolves.toBe(0)
|
|
})
|
|
|
|
it('validates placeholders and extra keys in present translations', async () => {
|
|
const en = { m: { greeting: 'Hello {{name}}' } }
|
|
const root = makeProject({
|
|
sourceText:
|
|
"import { t } from '@/i18n/mobile-i18n'\nexport const label = t('m.greeting', { name: 'Orca' })\n",
|
|
catalogs: {
|
|
en,
|
|
es: { m: { greeting: 'Hola {{wrongName}}', extra: 'Extra' } }
|
|
}
|
|
})
|
|
|
|
const report = await runFailedVerification(root)
|
|
expect(report).toContain('es.json placeholder mismatch: m.greeting')
|
|
expect(report).toContain('es.json has extra key: m.extra')
|
|
})
|
|
|
|
it('rejects encoded HTML entities that React Native would render literally', async () => {
|
|
const root = makeProject({
|
|
sourceText: "export const label = 'not rendered'\n",
|
|
catalogs: { en: { m: { label: 'Don't encode & characters' } } }
|
|
})
|
|
|
|
expect(await runFailedVerification(root)).toContain(
|
|
'en.json: m.label contains an encoded HTML entity'
|
|
)
|
|
})
|
|
|
|
it('rejects empty target translations before i18next can render a blank label', async () => {
|
|
const root = makeProject({
|
|
sourceText: "export const label = 'not rendered'\n",
|
|
catalogs: { en: { m: { label: 'Visible' } }, es: { m: { label: ' ' } } }
|
|
})
|
|
|
|
expect(await runFailedVerification(root)).toContain('es.json: m.label must not be empty')
|
|
})
|
|
|
|
it('rejects translated technical literals and commands', async () => {
|
|
const root = makeProject({
|
|
sourceText: "export const label = 'not rendered'\n",
|
|
catalogs: {
|
|
en: { m: { command: 'Run pnpm build from orca.yaml' } },
|
|
es: { m: { command: 'Ejecuta compilación pnpm desde Orca.yaml' } }
|
|
}
|
|
})
|
|
|
|
const report = await runFailedVerification(root)
|
|
expect(report).toContain('must preserve pnpm build')
|
|
expect(report).toContain('must preserve orca.yaml')
|
|
})
|
|
|
|
it('validates native locale paths, zh-Hans mapping, and required keys', async () => {
|
|
const appConfig = defaultAppConfig()
|
|
appConfig.expo.locales['zh-Hans'] = './locales/zh.json'
|
|
const root = makeProject({
|
|
sourceText: "export const label = 'not rendered'\n",
|
|
catalogs: { en: { m: { label: 'Visible' } } },
|
|
nativeCatalogs: {
|
|
es: { ios: { ...NATIVE_CATALOG.ios, NSCameraUsageDescription: undefined } }
|
|
},
|
|
appConfig
|
|
})
|
|
|
|
const report = await runFailedVerification(root)
|
|
expect(report).toContain('locale zh-Hans must map to ./locales/zh-Hans.json')
|
|
expect(report).toContain(
|
|
'mobile/locales/es.json missing required native key: ios.NSCameraUsageDescription'
|
|
)
|
|
})
|
|
|
|
it('keeps app-config fallbacks aligned with the English native catalog', async () => {
|
|
const appConfig = defaultAppConfig()
|
|
appConfig.expo.plugins[1][1].cameraPermission = 'Different fallback'
|
|
const root = makeProject({
|
|
sourceText: "export const label = 'not rendered'\n",
|
|
catalogs: { en: { m: { label: 'Visible' } } },
|
|
appConfig
|
|
})
|
|
|
|
expect(await runFailedVerification(root)).toContain(
|
|
'mobile/app.json English native fallback mismatch: ios.NSCameraUsageDescription'
|
|
)
|
|
})
|
|
})
|