Files
orca/config/scripts/verify-localization-catalog.test.mjs
T
6631e5f6b6 fix(i18n): give each reused localization key its own sentence (#34648)
* fix(i18n): give each reused localization key its own sentence

Thirteen keys were shared by call sites that say different things. The
catalog holds one value per key, so the second site rendered the first
site's text: "Move X left" on the right-move button, "Use X repo color"
on the icon picker, "lines above" on the expand-below control, "Open X
issues" on the open-in-Linear action, and similar for status icons,
reclaimable workspaces, remote uploads, automation destination notices,
the add-project button and MiniMax credential errors.

Split every divergent site onto its own key with translations in all
locales, sync the runtime-required catalog, and make
verify-localization-catalog reject a key reused with different fallback
text so the collision cannot come back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(i18n): pluralize the remote upload toast with i18next count

The toast passed an English "s" as {{value1}}, so ja/ko/zh rendered
"ファイルs", "파일s" and "文件s". The catalog gate requires every locale to
keep the source placeholders, so dropping {{value1}} per locale is not an
option; use count plurals (_one/_other) like nativeDropTempCopyFailed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: add one-line docstrings to the functions this change touches

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(i18n): reject reused fallback keys before --fix writes en.json

A missing key used with two different fallback sentences had its first
sentence written to en.json before the reused-fallback check failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matheus Breguêz <matbrgz@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-27 18:28:23 -07:00

259 lines
10 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('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()
}
})
})