mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
* perf(i18n): load built-in catalogs once, without copies or a runtime plural fill
Each catalog load parsed the JSON, rebuilt it to fill CLDR plural forms
from _other, then deep-copied and deep-merged it into the store (about
26 ms after parsing per 1.3 MB catalog), and concurrent loads of one
locale fetched it twice.
- sync:localization-catalog now commits the plural forms a locale's
CLDR rules need beside each _other (fr/es/pt many, tr one, a few
de/ru), and verify:localization-catalog fails when one is missing.
Plural forms may include {{count}} where English omits it.
- Renderer and main load built-in catalogs as-is; plugin packs keep the
runtime fill.
- Renderer catalog requests are shared per locale; a timed-out request
is dropped so a retry fetches again. Bundles are added with skipCopy.
* fix(i18n): require {{count}} in plural forms when English shows it
* refactor(i18n): share one i18next setup between main and renderer
- createOrcaI18n (src/shared/orca-i18n-instance.ts) owns the backend,
init options, plugin-pack registration, resource-language resolution
and the translate wrapper; main and renderer inject only how a
built-in catalog loads (dynamic import vs. fetch) and extra modules.
- Remove setRendererUiLanguage (tests only); tests load then switch.
- resolveUiLocale maps built-ins by lookup instead of an if-chain, and
catalog URLs/loaders are direct map lookups.
- Drop <I18nextProvider>: initReactI18next already binds the instance.
* refactor(i18n): native i18next plurals at call sites, plural-aware extraction
Call sites branched on count === 1 and passed literal _one/_other keys,
which resolvePluralFamilyKey stripped again so CLDR could pick the form.
They now call translate('x', otherDefault, { count, defaultValue_one })
and i18next selects the form natively; catalog keys stay x_one/x_other.
- Converted 284 families with an AST codemod plus a few by hand; added
defaultValue_one to 21 base-key families that relied on en.json.
- Extraction runs with plurals enabled, so both English defaults are
checked for drift. A key that only fills {{count}} (no defaultValue_one)
stays singular in en.json.
- verify/sync:localization-catalog expand a native plural call into its
x_one/x_other English entries.
- Removed 22 base keys that a sibling plural family shadowed in every
catalog.
* fix(i18n): pick English plural defaults by English rules; require count for plural refs
* refactor(i18n): convert plural sites merged from main; treat key-as-default plural extraction as dynamic
* fix(i18n): keep an undefined pass-through count absent; only dotted key-as-default is dynamic
* test(i18n): update plural call expectations in two renderer tests
* refactor(i18n): convert a plural site merged from main
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
341 lines
14 KiB
JavaScript
341 lines
14 KiB
JavaScript
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
collectGenericTermRegressions,
|
|
main as verifyLocalizationCatalog
|
|
} from './verify-localization-catalog.mjs'
|
|
|
|
function writeJson(filePath, value) {
|
|
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
}
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(readFileSync(filePath, 'utf8'))
|
|
}
|
|
|
|
function makeProject({ sourceText, enCatalog = {}, esCatalog = {} }) {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-catalog-'))
|
|
const rendererDir = path.join(root, 'src', 'renderer', 'src', 'components')
|
|
const mainDir = path.join(root, 'src', 'main')
|
|
const localesDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
|
|
|
|
mkdirSync(rendererDir, { recursive: true })
|
|
mkdirSync(mainDir, { recursive: true })
|
|
mkdirSync(localesDir, { recursive: true })
|
|
|
|
writeFileSync(path.join(rendererDir, 'Example.tsx'), sourceText, 'utf8')
|
|
writeFileSync(path.join(mainDir, 'empty.ts'), 'export {}\n', 'utf8')
|
|
writeJson(path.join(localesDir, 'en.json'), enCatalog)
|
|
writeJson(path.join(localesDir, 'es.json'), esCatalog)
|
|
|
|
return { root, localesDir }
|
|
}
|
|
|
|
describe('verify-localization-catalog', () => {
|
|
it('bootstraps English entries without fabricating target translations', async () => {
|
|
const { root, localesDir } = makeProject({
|
|
sourceText:
|
|
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n"
|
|
})
|
|
|
|
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
|
|
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
|
|
|
|
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
|
|
auto: { example: { greeting: 'Hello {{name}}' } }
|
|
})
|
|
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({})
|
|
})
|
|
|
|
it('bootstraps keys referenced only through translateSearchKeyword', async () => {
|
|
const { root, localesDir } = makeProject({
|
|
sourceText:
|
|
"import { translateSearchKeyword } from '@/components/settings/settings-search-keywords'\nexport const keywords = translateSearchKeyword('auto.components.settings.example.search.scroll', 'scroll')\n"
|
|
})
|
|
|
|
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
|
|
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
|
|
|
|
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
|
|
auto: { components: { settings: { example: { search: { scroll: 'scroll' } } } } }
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
['en', false],
|
|
['en', true],
|
|
['es', false]
|
|
])('rejects dotted %s keys without rewriting them (fix: %s)', async (locale, fix) => {
|
|
const nested = { auto: { example: { greeting: 'Hello.world' } } }
|
|
const { root, localesDir } = makeProject({
|
|
sourceText: 'export {}\n',
|
|
enCatalog: locale === 'en' ? { 'auto.example.greeting': 'Hello.world' } : nested,
|
|
esCatalog: locale === 'es' ? { auto: { 'example.greeting': 'Hola.world' } } : {}
|
|
})
|
|
const catalogPath = path.join(localesDir, `${locale}.json`)
|
|
const before = readFileSync(catalogPath, 'utf8')
|
|
const report = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
try {
|
|
await expect(verifyLocalizationCatalog(root, { fix })).resolves.toBe(1)
|
|
expect(report).toHaveBeenCalledWith(expect.stringContaining(`${locale}.json`))
|
|
expect(report).toHaveBeenCalledWith('dotted: auto.example.greeting')
|
|
expect(readFileSync(catalogPath, 'utf8')).toBe(before)
|
|
} finally {
|
|
report.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('never overwrites mismatched translations or removes target-only entries', async () => {
|
|
const { root, localesDir } = makeProject({
|
|
sourceText:
|
|
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
|
|
enCatalog: { auto: { example: { greeting: 'Hello {{name}}' } } },
|
|
esCatalog: {
|
|
auto: {
|
|
example: { greeting: 'Hola' },
|
|
stale: { removed: 'Viejo' }
|
|
}
|
|
}
|
|
})
|
|
|
|
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
|
|
|
|
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
|
|
auto: {
|
|
example: { greeting: 'Hola' },
|
|
stale: { removed: 'Viejo' }
|
|
}
|
|
})
|
|
})
|
|
|
|
it('accepts sparse target catalogs when existing placeholders match', async () => {
|
|
const { root } = makeProject({
|
|
sourceText:
|
|
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
|
|
enCatalog: {
|
|
auto: { example: { greeting: 'Hello {{name}}', untranslated: 'English only' } }
|
|
},
|
|
esCatalog: { auto: { example: { greeting: 'Hola {{name}}' } } }
|
|
})
|
|
|
|
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
|
|
})
|
|
|
|
it('rejects one key reused for two different sentences', async () => {
|
|
const { root } = makeProject({
|
|
sourceText:
|
|
"import { translate } from '@/i18n/i18n'\nexport const left = translate('auto.example.move', 'Move {{value0}} left', { value0: 'A' })\nexport const right = translate('auto.example.move', 'Move {{value0}} right', { value0: 'A' })\n",
|
|
enCatalog: { auto: { example: { move: 'Move {{value0}} left' } } }
|
|
})
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
|
|
expect(error.mock.calls.flat().join('\n')).toContain('auto.example.move')
|
|
error.mockRestore()
|
|
})
|
|
|
|
it('refuses to --fix a missing key reused for two different sentences', async () => {
|
|
const { root, localesDir } = makeProject({
|
|
sourceText:
|
|
"import { translate } from '@/i18n/i18n'\nexport const left = translate('auto.example.move', 'Move left')\nexport const right = translate('auto.example.move', 'Move right')\n"
|
|
})
|
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
|
|
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
|
|
expect(error.mock.calls.flat().join('\n')).toContain('auto.example.move')
|
|
error.mockRestore()
|
|
log.mockRestore()
|
|
})
|
|
|
|
it('does not invent values for keys without string fallbacks', async () => {
|
|
const { root, localesDir } = makeProject({
|
|
sourceText:
|
|
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.noFallback')\n"
|
|
})
|
|
|
|
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
|
|
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
|
|
})
|
|
|
|
it('accepts Russian few/many forms only for an existing plural family', async () => {
|
|
const { root, localesDir } = makeProject({
|
|
sourceText: 'export {}\n',
|
|
enCatalog: { images_one: '{{count}} image', images_other: '{{count}} images' }
|
|
})
|
|
const russianPath = path.join(localesDir, 'ru.json')
|
|
writeJson(russianPath, {
|
|
images_few: '{{count}} изображения',
|
|
images_many: '{{count}} изображений'
|
|
})
|
|
await expect(verifyLocalizationCatalog(root)).resolves.toBe(0)
|
|
|
|
writeJson(russianPath, { unknown_many: '{{count}} изображений' })
|
|
await expect(verifyLocalizationCatalog(root)).resolves.toBe(1)
|
|
writeJson(russianPath, { images_many: '{{wrongCount}} изображений' })
|
|
await expect(verifyLocalizationCatalog(root)).resolves.toBe(1)
|
|
writeJson(russianPath, { images_two: '{{count}} изображения' })
|
|
await expect(verifyLocalizationCatalog(root)).resolves.toBe(1)
|
|
})
|
|
|
|
it('requires both English forms of a native plural call', async () => {
|
|
const { root, localesDir } = makeProject({
|
|
sourceText:
|
|
"import { translate } from '@/i18n/i18n'\nexport const files = (count: number) => translate('auto.example.files', '{{count}} files', { count, defaultValue_one: 'One file' })\nexport const selected = (count: number) => translate('auto.example.selected', '{{count}} selected', { count })\n",
|
|
enCatalog: { auto: { example: { selected: '{{count}} selected' } } }
|
|
})
|
|
const report = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
try {
|
|
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
|
|
} finally {
|
|
report.mockRestore()
|
|
}
|
|
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
|
|
|
|
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
|
|
auto: {
|
|
example: {
|
|
selected: '{{count}} selected',
|
|
files_one: 'One file',
|
|
files_other: '{{count}} files'
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
it('commits CLDR plural forms a locale lacks beside its _other form', async () => {
|
|
const { root, localesDir } = makeProject({
|
|
sourceText: 'export {}\n',
|
|
enCatalog: {
|
|
files_one: 'One file',
|
|
files_other: '{{count}} files',
|
|
name: 'Name'
|
|
}
|
|
})
|
|
const frenchPath = path.join(localesDir, 'fr.json')
|
|
writeJson(frenchPath, { files_other: '{{count}} fichiers', name: 'Nom' })
|
|
const report = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
try {
|
|
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
|
|
} finally {
|
|
report.mockRestore()
|
|
}
|
|
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
|
|
|
|
expect(Object.entries(readJson(frenchPath))).toEqual([
|
|
['files_other', '{{count}} fichiers'],
|
|
['files_one', '{{count}} fichiers'],
|
|
['files_many', '{{count}} fichiers'],
|
|
['name', 'Nom']
|
|
])
|
|
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
|
|
|
|
// Only an English form without {{count}} lets the locale add it; dropping it is still a mismatch.
|
|
writeJson(frenchPath, { files_other: 'fichiers', files_one: 'fichier', files_many: 'fichiers' })
|
|
const mismatch = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
try {
|
|
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
|
|
} finally {
|
|
mismatch.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('reports partial plugin catalog gaps but rejects malformed interpolation', async () => {
|
|
const { root } = makeProject({
|
|
sourceText: 'export {}\n',
|
|
enCatalog: {
|
|
auto: { first: 'First {{name}}', second: 'Second' }
|
|
},
|
|
esCatalog: {
|
|
auto: { first: 'Primero {{name}}', second: 'Segundo' }
|
|
}
|
|
})
|
|
const pluginCatalogPath = path.join(root, 'plugin-locale.json')
|
|
writeJson(pluginCatalogPath, {
|
|
auto: { first: 'Primeiro {{wrongName}}', pluginOnly: 'Plugin only' }
|
|
})
|
|
const report = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
|
|
try {
|
|
await expect(
|
|
verifyLocalizationCatalog(root, {
|
|
fix: false,
|
|
pluginCatalogs: [pluginCatalogPath]
|
|
})
|
|
).resolves.toBe(1)
|
|
expect(report).toHaveBeenCalledWith(expect.stringContaining('0/2 core keys'))
|
|
expect(report).toHaveBeenCalledWith(expect.stringContaining('interpolation mismatch'))
|
|
} finally {
|
|
report.mockRestore()
|
|
}
|
|
})
|
|
|
|
// Why: #12113 — parity checks passed while the repair policy rewrote translated terms to English.
|
|
it('flags catalog values the repair policy would rewrite back to English', () => {
|
|
const enEntries = new Map([['auto.example.commitLabel', 'Commit message']])
|
|
|
|
expect(
|
|
collectGenericTermRegressions(
|
|
enEntries,
|
|
new Map([['auto.example.commitLabel', 'mensaje de confirmación']]),
|
|
'es'
|
|
)
|
|
).toEqual([])
|
|
|
|
// A locale whose committed value is the English term is stable, not a regression.
|
|
expect(
|
|
collectGenericTermRegressions(
|
|
enEntries,
|
|
new Map([['auto.example.commitLabel', 'mensaje de Commit']]),
|
|
'es'
|
|
)
|
|
).toEqual([])
|
|
|
|
// 'Comprometerse' is a real mistranslation, so reverting it to Latin is expected.
|
|
expect(
|
|
collectGenericTermRegressions(
|
|
enEntries,
|
|
new Map([['auto.example.commitLabel', 'mensaje de Comprometerse']]),
|
|
'es'
|
|
)
|
|
).toEqual([])
|
|
})
|
|
|
|
it('ignores interpolation names when looking for English rewrites', () => {
|
|
expect(
|
|
collectGenericTermRegressions(
|
|
new Map([
|
|
['components.agentSessionContinuation.originalAgent', 'Original agent: {{agent}}']
|
|
]),
|
|
new Map([['components.agentSessionContinuation.originalAgent', '原智能体:{{agent}}']]),
|
|
'zh'
|
|
)
|
|
).toEqual([])
|
|
})
|
|
|
|
it('fails when a target catalog repeats a key', async () => {
|
|
const { root, localesDir } = makeProject({
|
|
sourceText:
|
|
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello')\n",
|
|
enCatalog: { auto: { example: { greeting: 'Hello' } } }
|
|
})
|
|
writeFileSync(
|
|
path.join(localesDir, 'es.json'),
|
|
'{\n "auto": {\n "example": {\n "greeting": "Hola",\n "greeting": "Hola"\n }\n }\n}\n',
|
|
'utf8'
|
|
)
|
|
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
try {
|
|
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
|
|
expect(report).toHaveBeenCalledWith('- es.json:5 auto.example.greeting')
|
|
} finally {
|
|
report.mockRestore()
|
|
}
|
|
})
|
|
})
|