mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:15 +00:00
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
This commit is contained in:
@@ -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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
type ParsedTranslationFile = {
|
||||
file: string;
|
||||
flatMessages: Record<string, string>;
|
||||
};
|
||||
|
||||
type ScanResult = {
|
||||
usedKeys: Set<string>;
|
||||
referencedStaticKeys: Set<string>;
|
||||
@@ -48,6 +61,90 @@ function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||
return keys;
|
||||
}
|
||||
|
||||
function flattenTranslations(
|
||||
obj: NestedTranslations,
|
||||
file: string,
|
||||
parentKey = "",
|
||||
result: Record<string, string> = {},
|
||||
errors: string[] = [],
|
||||
): Record<string, string> {
|
||||
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<string, unknown>));
|
||||
|
||||
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<string>();
|
||||
@@ -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(
|
||||
Reference in New Issue
Block a user