From 12733900a40ae3b204c03f1f539863497d6d9de5 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:31:50 +0100 Subject: [PATCH] test(i18n): validate ICU syntax across all translation files (#3170) ## Description: This PR adds a translation validation test to catch malformed ICU message syntax during test runs instead of only at runtime. ## What changed - Added `tests/LangIcuMessages.test.ts`. - The test scans all `resources/lang/*.json` files (excluding `metadata.json`). - It flattens nested translation objects into dot-keys. - It validates each translation string by compiling it with `IntlMessageFormat`. - It fails with explicit `file:key` errors for: - Invalid ICU syntax - Invalid translation value types (non-string leaves) ## Why Today malformed translation strings only surface as console warnings at runtime. This test moves detection into CI/test execution, giving fast and deterministic feedback. ## How to run ```bash npx vitest run tests/LangIcuMessages.test.ts ``` ## Notes The new test currently surfaces existing malformed ICU strings (not introduced by this PR), especially `send_troops_modal.slider_tooltip`, `send_troops_modal.capacity_note`, and `send_gold_modal.slider_tooltip` in multiple locale files. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: DISCORD_USERNAME --- tests/LangMetadata.test.ts | 62 ------ ...Keys.test.ts => TranslationSystem.test.ts} | 186 +++++++++++++++++- 2 files changed, 180 insertions(+), 68 deletions(-) delete mode 100644 tests/LangMetadata.test.ts rename tests/{UnusedTranslationKeys.test.ts => TranslationSystem.test.ts} (71%) diff --git a/tests/LangMetadata.test.ts b/tests/LangMetadata.test.ts deleted file mode 100644 index 7a5193f6a..000000000 --- a/tests/LangMetadata.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import fs from "fs"; -import path from "path"; - -describe("Lang Metadata Check", () => { - const langDir = path.join(__dirname, "../resources/lang"); - const flagDir = path.join(__dirname, "../resources/flags"); - const metadataFile = path.join(langDir, "metadata.json"); - - test("metadata languages point to existing lang json and flag files", () => { - if (!fs.existsSync(metadataFile)) { - console.log( - "No resources/lang/metadata.json file found. Skipping check.", - ); - return; - } - - const metadata = JSON.parse(fs.readFileSync(metadataFile, "utf-8")); - if (!Array.isArray(metadata) || metadata.length === 0) { - console.log( - "No language entries found in metadata.json. Skipping check.", - ); - return; - } - - const errors: string[] = []; - - for (const entry of metadata) { - const code = entry?.code; - const svg = entry?.svg; - if (typeof code !== "string" || code.length === 0) { - errors.push( - `metadata entry missing valid code: ${JSON.stringify(entry)}`, - ); - continue; - } - if (typeof svg !== "string" || svg.length === 0) { - errors.push( - `[${code}]: metadata svg is missing or not a non-empty string`, - ); - continue; - } - - const langFilePath = path.join(langDir, `${code}.json`); - if (!fs.existsSync(langFilePath)) { - errors.push(`[${code}]: lang json file does not exist: ${code}.json`); - } - - const svgFile = svg.endsWith(".svg") ? svg : `${svg}.svg`; - const flagPath = path.join(flagDir, svgFile); - if (!fs.existsSync(flagPath)) { - errors.push(`[${code}]: SVG file does not exist: ${svgFile}`); - } - } - - if (errors.length > 0) { - console.error( - "Metadata lang or SVG file check failed:\n" + errors.join("\n"), - ); - expect(errors).toEqual([]); - } - }); -}); diff --git a/tests/UnusedTranslationKeys.test.ts b/tests/TranslationSystem.test.ts similarity index 71% rename from tests/UnusedTranslationKeys.test.ts rename to tests/TranslationSystem.test.ts index 46e396cc6..9bffa5e0a 100644 --- a/tests/UnusedTranslationKeys.test.ts +++ b/tests/TranslationSystem.test.ts @@ -1,7 +1,13 @@ import fs from "fs"; +import IntlMessageFormat from "intl-messageformat"; import path from "path"; import ts from "typescript"; +const PROJECT_ROOT = path.join(__dirname, ".."); +const LANGUAGE_DIR = path.join(PROJECT_ROOT, "resources", "lang"); +const FLAG_DIR = path.join(PROJECT_ROOT, "resources", "flags"); +const METADATA_FILE = path.join(LANGUAGE_DIR, "metadata.json"); + /** * Regex patterns for keys that are intentionally generated dynamically. * This keeps dynamic handling explicit and reviewable. @@ -29,6 +35,13 @@ const IGNORED_UNUSED_KEY_PATTERNS: RegExp[] = [ /^lang\./, // language metadata, not a UI translation key ]; +type NestedTranslations = Record; + +type ParsedTranslationFile = { + file: string; + flatMessages: Record; +}; + type ScanResult = { usedKeys: Set; referencedStaticKeys: Set; @@ -48,6 +61,90 @@ function flattenKeys(obj: Record, prefix = ""): string[] { return keys; } +function flattenTranslations( + obj: NestedTranslations, + file: string, + parentKey = "", + result: Record = {}, + errors: string[] = [], +): Record { + for (const [key, value] of Object.entries(obj)) { + const fullKey = parentKey ? `${parentKey}.${key}` : key; + if (typeof value === "string") { + result[fullKey] = value; + continue; + } + if (value && typeof value === "object" && !Array.isArray(value)) { + flattenTranslations( + value as NestedTranslations, + file, + fullKey, + result, + errors, + ); + continue; + } + errors.push( + `${file}:${fullKey} has invalid type ${Array.isArray(value) ? "array" : typeof value}`, + ); + } + return result; +} + +function listLanguageJsonFiles(): string[] { + return fs + .readdirSync(LANGUAGE_DIR) + .filter((file) => file.endsWith(".json") && file !== "metadata.json") + .sort(); +} + +function loadTranslationFiles(): { + files: ParsedTranslationFile[]; + errors: string[]; +} { + const errors: string[] = []; + const files: ParsedTranslationFile[] = []; + + for (const file of listLanguageJsonFiles()) { + const fullPath = path.join(LANGUAGE_DIR, file); + let raw: string; + try { + raw = fs.readFileSync(fullPath, "utf-8"); + } catch (error) { + const details = error instanceof Error ? error.message : String(error); + errors.push(`${file}: failed to read file (${details})`); + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + const details = error instanceof Error ? error.message : String(error); + errors.push(`${file}: invalid JSON (${details})`); + continue; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + errors.push(`${file}: root must be an object`); + continue; + } + + files.push({ + file, + flatMessages: flattenTranslations( + parsed as NestedTranslations, + file, + "", + {}, + errors, + ), + }); + } + + return { files, errors }; +} + function getAllFiles( dir: string, extensions: string[], @@ -335,15 +432,92 @@ function scanTsFile( return result; } -describe("Unused Translation Keys", () => { +describe("Translation System", () => { + test("metadata languages point to existing lang json and flag files", () => { + if (!fs.existsSync(METADATA_FILE)) { + console.log( + "No resources/lang/metadata.json file found. Skipping check.", + ); + return; + } + + const metadata = JSON.parse(fs.readFileSync(METADATA_FILE, "utf-8")); + if (!Array.isArray(metadata) || metadata.length === 0) { + console.log( + "No language entries found in metadata.json. Skipping check.", + ); + return; + } + + const knownLanguageFiles = new Set(listLanguageJsonFiles()); + const errors: string[] = []; + + for (const entry of metadata) { + const code = entry?.code; + const svg = entry?.svg; + + if (typeof code !== "string" || code.length === 0) { + errors.push( + `metadata entry missing valid code: ${JSON.stringify(entry)}`, + ); + continue; + } + + if (typeof svg !== "string" || svg.length === 0) { + errors.push( + `[${code}]: metadata svg is missing or not a non-empty string`, + ); + continue; + } + + if (!knownLanguageFiles.has(`${code}.json`)) { + errors.push(`[${code}]: lang json file does not exist: ${code}.json`); + } + + const svgFile = svg.endsWith(".svg") ? svg : `${svg}.svg`; + const flagPath = path.join(FLAG_DIR, svgFile); + if (!fs.existsSync(flagPath)) { + errors.push(`[${code}]: SVG file does not exist: ${svgFile}`); + } + } + + if (errors.length > 0) { + console.error( + "Metadata lang or SVG file check failed:\n" + errors.join("\n"), + ); + } + expect(errors).toEqual([]); + }); + + test("all translation strings are valid ICU messages", () => { + const { files, errors } = loadTranslationFiles(); + + for (const { file, flatMessages } of files) { + for (const [key, message] of Object.entries(flatMessages)) { + try { + new IntlMessageFormat(message, "en"); + } catch (error) { + const details = + error instanceof Error ? error.message : String(error); + errors.push(`${file}:${key} has invalid ICU syntax (${details})`); + } + } + } + + if (errors.length > 0) { + console.error("ICU translation validation failed:\n" + errors.join("\n")); + } + expect(errors).toEqual([]); + }); + test("en.json keys stay in sync with source usage", () => { - const enJsonPath = path.join(__dirname, "../resources/lang/en.json"); + const enJsonPath = path.join(LANGUAGE_DIR, "en.json"); const enJson = JSON.parse(fs.readFileSync(enJsonPath, "utf-8")); const allKeys = flattenKeys(enJson); const enKeySet = new Set(allKeys); const rootKeys = new Set(Object.keys(enJson as Record)); - const srcDir = path.join(__dirname, "../src"); + const srcDir = path.join(PROJECT_ROOT, "src"); const sourceFiles = getAllFiles(srcDir, [".ts", ".tsx", ".js", ".jsx"]); const usedKeys = new Set(); @@ -353,12 +527,13 @@ describe("Unused Translation Keys", () => { for (const file of sourceFiles) { const scan = scanTsFile(file, rootKeys, enKeySet); for (const key of scan.usedKeys) usedKeys.add(key); - for (const key of scan.referencedStaticKeys) + for (const key of scan.referencedStaticKeys) { referencedStaticKeys.add(key); + } for (const prefix of scan.dynamicPrefixes) dynamicPrefixes.add(prefix); } - const indexHtmlPath = path.join(__dirname, "../index.html"); + const indexHtmlPath = path.join(PROJECT_ROOT, "index.html"); if (fs.existsSync(indexHtmlPath)) { const htmlContent = fs.readFileSync(indexHtmlPath, "utf-8"); const htmlDataI18nKeys = extractDataI18nKeys(htmlContent); @@ -412,7 +587,6 @@ describe("Unused Translation Keys", () => { } const hasFailing = missingKeys.length > 0 || unusedKeys.length > 0; - if (hasFailing) { if (derivedDynamicPatterns.length > 0) { console.log(