mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
fix(i18n): restore three placeholders dropped from the pt catalogue (#11325)
Validated on a 17-PR combined board: i18n-placeholder-parity within the board's 287/287, typecheck:core clean. Restores 3 dropped placeholders in pt.json (the visible one: the cache tile's subtitle was repeating its own label instead of showing the total) and adds a 42-locale placeholder-set gate so this class of drift can't recur silently. Thank you @ntdat812!
This commit is contained in:
1
changelog.d/fixes/11325-i18n-pt-placeholder-parity.md
Normal file
1
changelog.d/fixes/11325-i18n-pt-placeholder-parity.md
Normal file
@@ -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))
|
||||
@@ -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",
|
||||
|
||||
94
tests/unit/i18n-placeholder-parity.test.ts
Normal file
94
tests/unit/i18n-placeholder-parity.test.ts
Normal file
@@ -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<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
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<string> {
|
||||
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")], []);
|
||||
});
|
||||
Reference in New Issue
Block a user