diff --git a/changelog.d/fixes/11325-i18n-pt-placeholder-parity.md b/changelog.d/fixes/11325-i18n-pt-placeholder-parity.md new file mode 100644 index 0000000000..97a2370955 --- /dev/null +++ b/changelog.d/fixes/11325-i18n-pt-placeholder-parity.md @@ -0,0 +1 @@ +- **fix(i18n):** three `pt` strings had dropped their placeholders — the cache tile's subtitle repeated its own label instead of showing `{total}` — and a unit test now enforces placeholder parity with `en` across all locales ([#11325](https://github.com/diegosouzapw/OmniRoute/pull/11325)) diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 3194c7e492..a20bd38f1d 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4242,7 +4242,7 @@ "smokeSendSuccessWithTask": "message/send ok (tarefa {taskId}).", "smokeSendSuccess": "message/send ok.", "smokeStreamFailed": "Teste de fumo message/stream falhou.", - "smokeStreamSuccessWithTask": "message/stream ok (tarefa {taskId}).", + "smokeStreamSuccessWithTask": "message/stream ok (tarefa {taskId}{stateSuffix}).", "smokeStreamNoTaskId": "message/stream terminou sem ID de tarefa.", "health": "Estado de saúde", "ok": "OK", @@ -10202,7 +10202,7 @@ "scanning": "A analisar...", "opencodeIntegration": "Integração OpenCode", "opencodeDetected": "opencode {version} detetado", - "opencodeDesc": "Gera um {configFile} pronto a usar com a tua configuração OmniRoute", + "opencodeDesc": "Gera um {configFile} pronto a usar com o URL base do OmniRoute e todos os modelos disponíveis — coloca-o na raiz do teu projeto e executa {command}.", "downloadConfig": "Descarregar {file}", "downloaded": "Descarregado!", "setupGuideTitle": "Guia de configuração", @@ -10395,7 +10395,7 @@ "dbEntries": "Entradas na BD", "dbEntriesSub": "Persistido (SQLite)", "cacheHits": "Acertos de cache", - "cacheHitsSub": "Acertos", + "cacheHitsSub": "de {total} no total", "tokensSaved": "Tokens Poupançados", "tokensSavedSub": "Estimado a partir de acertos", "hitRate": "Taxa de acertos", diff --git a/tests/unit/i18n-placeholder-parity.test.ts b/tests/unit/i18n-placeholder-parity.test.ts new file mode 100644 index 0000000000..fcb4f75e79 --- /dev/null +++ b/tests/unit/i18n-placeholder-parity.test.ts @@ -0,0 +1,94 @@ +// A translation that drops a placeholder silently loses the value it carried: +// the string still renders, just without the number, path or command the +// English copy promised. Nothing checked for that, and three strings had +// drifted (all in `pt`): +// +// a2aDashboard.smokeStreamSuccessWithTask lost {stateSuffix} +// agents.opencodeDesc lost {command} +// cache.cacheHitsSub lost {total} ("of {total} total" -> "Acertos") +// +// Placeholder sets are compared, not counts or order: a locale may reorder or +// repeat them, but it may not introduce one English never defined (it would +// render literally) or drop one (its value disappears). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const messagesDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "src", + "i18n", + "messages" +); + +type Json = { [key: string]: string | Json }; + +function loadLocale(file: string): Json { + return JSON.parse(readFileSync(path.join(messagesDir, file), "utf8")) as Json; +} + +function flatten(value: Json, prefix = ""): Map { + const out = new Map(); + for (const [key, child] of Object.entries(value)) { + const dotted = prefix ? `${prefix}.${key}` : key; + if (typeof child === "string") out.set(dotted, child); + else if (child && typeof child === "object") { + for (const [k, v] of flatten(child, dotted)) out.set(k, v); + } + } + return out; +} + +/** + * Names an ICU message interpolates: `{name}` and the argument of a typed + * placeholder such as `{count, plural, ...}`. Nested sub-messages are covered + * because the scan is a plain sweep of the whole string. + */ +function placeholders(message: string): Set { + return new Set( + [...message.matchAll(/\{\s*([a-zA-Z0-9_]+)\s*[,}]/g)].map((match) => match[1]) + ); +} + +const english = flatten(loadLocale("en.json")); +const locales = readdirSync(messagesDir) + .filter((file) => file.endsWith(".json") && file !== "en.json") + .sort(); + +test("every locale keeps the placeholders its English source defines", () => { + const drift: string[] = []; + + for (const file of locales) { + for (const [key, translated] of flatten(loadLocale(file))) { + const source = english.get(key); + if (typeof source !== "string") continue; + + const expected = placeholders(source); + const actual = placeholders(translated); + const missing = [...expected].filter((name) => !actual.has(name)); + const unknown = [...actual].filter((name) => !expected.has(name)); + if (missing.length === 0 && unknown.length === 0) continue; + + drift.push( + `${file} ${key}\n` + + ` en: ${source}\n` + + ` ${file.replace(".json", "")}: ${translated}\n` + + ` missing=[${missing.join(", ")}] unknown=[${unknown.join(", ")}]` + ); + } + } + + assert.deepEqual(drift, [], `\n placeholder drift:\n ${drift.join("\n ")}\n`); +}); + +test("the checker itself recognises the drift it is meant to catch", () => { + // Without this the test above could pass by never matching anything. + assert.deepEqual([...placeholders("of {total} total")], ["total"]); + assert.deepEqual([...placeholders("ok (task {taskId}{stateSuffix}).")], ["taskId", "stateSuffix"]); + assert.deepEqual([...placeholders("{count, plural, one {# item} other {# items}}")], ["count"]); + assert.deepEqual([...placeholders("Acertos")], []); +});