From e7676b426063719af1f42488787e12c9277b87e9 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 8 Feb 2026 22:21:50 +0000 Subject: [PATCH] check if translations are being used in the code (en.json test) (#3158) ## Description: Test if translation is being used from en.json test with a small bugfix for a regression that happened in an old pr ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] 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: w.o.n --- resources/lang/en.json | 75 +---- src/client/Utils.ts | 28 +- tests/UnusedTranslationKeys.test.ts | 449 ++++++++++++++++++++++++++++ 3 files changed, 479 insertions(+), 73 deletions(-) create mode 100644 tests/UnusedTranslationKeys.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 2bcf1a731..1e7c2d6b7 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -8,6 +8,7 @@ "common": { "close": "Close", "copy": "Copy", + "paste": "Paste", "back": "Back", "available": "Available", "preset_max": "Max", @@ -26,38 +27,29 @@ }, "main": { "title": "OpenFront (ALPHA)", - "join_discord": "Discord", "login_discord": "Login with Discord", "sign_in": "Sign in", "discord_avatar_alt": "Discord profile avatar", "user_avatar_alt": "{username}'s avatar", - "checking_login": "Checking login...", - "logged_in": "Logged in!", - "log_out": "Log out", "create": "Create Lobby", "join": "Join Lobby", "solo": "Solo", - "instructions": "Instructions", "game_info": "Game info", "wiki": "Wiki", "privacy_policy": "Privacy Policy", "terms_of_service": "Terms of Service", "copyright": "© OpenFront™ and Contributors", - "reddit": "Reddit", "play": "Play", "news": "News", "store": "Store", "store_new_badge": "NEW", "settings": "Settings", - "keys": "Keys", - "stats": "Stats", "leaderboard": "Leaderboard", "account": "Account", "help": "Help", "menu": "Menu", "troubleshooting": "Troubleshooting", - "go_to_troubleshooting": "Go to our troubleshooting page", - "pick_pattern": "Pick a pattern!" + "go_to_troubleshooting": "Go to our troubleshooting page" }, "news": { "github_link": "on GitHub", @@ -65,7 +57,6 @@ }, "troubleshooting": { "title": "Troubleshooting", - "loading": "Loading...", "environment": "Environment", "rendering": "Rendering", "power": "Power", @@ -114,7 +105,6 @@ "ui_leaderboard_desc": "Shows the top players of the game and their names, % owned land, gold and troops. Using Show All shows all players in the game. If you don't want to see the leaderboard, click Hide.", "ui_control": "Control panel", "ui_control_desc": "The control panel contains the following elements:", - "ui_pop": "Pop - The amount of units you have, your max population and the rate at which you gain them.", "ui_gold": "Gold - The amount of gold you have and the rate at which you gain it.", "ui_attack_ratio": "Attack ratio - The amount of troops that will be used when you attack. You can adjust the attack ratio using the slider. Having more attacking troops than defending troops will make you lose fewer troops in the attack, while having less will increase the damage dealt to your attacking troops. The effect doesn't go beyond ratios of 2:1.", "ui_events": "Event panel", @@ -134,12 +124,10 @@ "radial_title": "Radial menu", "radial_desc": "Right clicking (or touch on mobile) opens the Radial menu. Right click outside it to close it. From the menu you can:", "radial_build": "Open the Build menu.", - "radial_attack": "Open the Attack menu.", "radial_info": "Open the Info menu.", "radial_boat": "Send a Boat (transport ship) to attack at the selected location. Only available if you have access to water.", "radial_donate_troops": "Donate troops equivalent to your attack ratio slider percentage to the ally you opened the radial menu on.", "radial_donate_gold": "Opens the gold donation slider menu so you can quickly send allies gold.", - "radial_close": "Close the menu.", "info_title": "Info menu", "info_enemy_desc": "Contains information such as the selected player's name, gold, troops, stopped trading with you, nukes sent to you, and if the player is a traitor. Stopped trading means you won't receive gold from them and they won't sent you gold via trade ships. Manually (if the player clicked \"Stop trading\", which lasts until you both click \"Start trading\") or automatically (if you betrayed your alliance, which lasts until you become allies again or after 5 minutes). Traitor displays Yes for 30 seconds when the player betrayed and attacked a player who was in an alliance with them. The icons below represent the following interactions:", "info_chat": "Send a quick chat message to the player. Select a Category, a Phrase, and if the phrase contains [P1] select a Player name to replace it with. Hit Send.", @@ -192,25 +180,20 @@ "icon_alt_team_leaderboard": "Team Leaderboard Icon" }, "single_modal": { - "title": "Solo", "random_spawn": "Random spawn", - "allow_alliances": "Allow alliances", "toggle_achievements": "Toggle achievements", "sign_in_for_achievements": "Sign in for achievements", "options_title": "Options", "bots": "Bots: ", "bots_disabled": "Disabled", - "nations": "Nations: ", "disable_nations": "Disable Nations", "instant_build": "Instant build", "infinite_gold": "Infinite gold", "infinite_troops": "Infinite troops", "compact_map": "Compact Map", - "crowded": "Crowded", "max_timer": "Game length (minutes)", "max_timer_placeholder": "Mins", "max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)", - "disable_nukes": "Disable Nukes", "enables_title": "Enable Settings", "start": "Start Game", "options_changed_no_achievements": "Custom settings – achievements disabled", @@ -245,9 +228,6 @@ }, "leaderboard_modal": { "title": "Leaderboard", - "title_plural": "Leaderboards", - "clan_stats": "Clan Stats", - "player_stats": "1v1 Ranked Stats", "ranked_tab": "1v1 Ranked", "clans_tab": "Clans", "loading": "Loading...", @@ -363,10 +343,7 @@ "private_lobby": { "title": "Join Private Lobby", "enter_id": "Enter Lobby ID", - "player": "Player", - "players": "Players", "join_lobby": "Join Lobby", - "checking": "Checking lobby...", "not_found": "Lobby not found. Please check the ID and try again.", "error": "An error occurred. Please try again or contact support.", "joined_waiting": "Lobby joined! Waiting for host to start...", @@ -376,7 +353,6 @@ "public_lobby": { "title": "Waiting for Game Start...", "join": "Join next Game", - "waiting": "players waiting", "teams_Duos": "{team_count} teams of 2 (Duos)", "teams_Trios": "{team_count} teams of 3 (Trios)", "teams_Quads": "{team_count} teams of 4 (Quads)", @@ -419,7 +395,6 @@ "bots": "Bots: ", "bots_disabled": "Disabled", "player_immunity_duration": "PVP immunity duration (minutes)", - "nations": "Nations: ", "disable_nations": "Disable Nations", "max_timer": "Game length (minutes)", "mins_placeholder": "Mins", @@ -446,10 +421,7 @@ "teams_Trios": "Trios (teams of 3)", "teams_Quads": "Quads (teams of 4)", "teams_Humans Vs Nations": "Humans vs Nations", - "gold_multiplier": "Gold multiplier", - "gold_multiplier_placeholder": "2.0x", "starting_gold": "Starting gold", - "starting_gold_placeholder": "5000000", "crowded": "Crowded modifier" }, "team_colors": { @@ -476,8 +448,7 @@ }, "game_mode": { "ffa": "Free for All", - "teams": "Teams", - "humans_vs_nations": "Humans vs Nations" + "teams": "Teams" }, "public_game_modifier": { "random_spawn": "Random Spawn", @@ -720,18 +691,13 @@ "spectate": "Spectate", "requeue": "Play Again", "wishlist": "Wishlist on Steam!", - "ofm_winter": "OpenFront Masters Winter Tournament!", - "ofm_winter_description": "Join the competitive tournament and compete against the best players", - "join_tournament": "Join Tournament", "join_discord": "Join Our Discord Community!", "discord_description": "Connect with players, discover new features, and win prizes!", "join_server": "Join Server", "youtube_tutorial": "Need some help?" }, "leaderboard": { - "title": "Leaderboard", "hide": "Hide", - "rank": "Rank", "player": "Player", "team": "Team", "owned": "Owned", @@ -787,15 +753,6 @@ "unit_destroyed": "Your {unit} was destroyed", "no_boats_available": "No boats available, max {max}" }, - "unit_info_modal": { - "structure_info": "Structure Info", - "unit_type_unknown": "Unknown", - "close": "Close", - "cooldown": "Cooldown", - "type": "Type", - "upgrade": "Upgrade", - "level": "Level" - }, "player_type": { "player": "Player", "nation": "Nation", @@ -808,11 +765,6 @@ "friendly": "Friendly", "default": "Default" }, - "control_panel": { - "gold": "Gold", - "troops": "Troops", - "attack_ratio": "Attack Ratio" - }, "player_panel": { "gold": "Gold", "troops": "Troops", @@ -822,18 +774,14 @@ "active": "Active", "stopped": "Stopped", "alliance_time_remaining": "Alliance Expires In", - "embargo": "Stopped trading with you", - "nuke": "Nukes sent by them to you", "start_trade": "Start Trading", "stop_trade": "Stop Trading", "stop_trade_all": "Stop Trading with All", "start_trade_all": "Start Trading with All", "alliances": "Alliances", - "flag": "Flag", "chat": "Chat", "target": "Target", "break_alliance": "Break Alliance", - "alliance": "Alliance", "send_alliance": "Send Alliance", "send_troops": "Send Troops", "send_gold": "Send Gold", @@ -853,7 +801,6 @@ "send_troops_modal": { "title_with_name": "Send Troops to {name}", "available_tooltip": "Your current available troops", - "min_keep": "Min keep", "slider_tooltip": "{{percent}}% • {{amount}}", "aria_slider": "Troops slider", "capacity_note": "Receiver can accept only {{amount}} right now." @@ -908,10 +855,6 @@ "show_only_owned": "My Skins", "all_owned": "All skins owned! Check back later for new items.", "not_logged_in": "Not logged in", - "blocked": { - "login": "You must be logged in to access this skin.", - "purchase": "Purchase this skin to unlock it." - }, "pattern": { "default": "Default" }, @@ -923,15 +866,6 @@ "button_title": "Pick a flag!", "search_flag": "Search..." }, - "spawn_ad": { - "loading": "Loading advertisement..." - }, - "auth": { - "login_required": "Login is required to access this website.", - "redirecting": "You are being redirected...", - "not_authorized": "You are not authorized to access this website.", - "contact_admin": "If you believe you are seeing this message in error, please contact the website administrator." - }, "radial_menu": { "delete_unit_title": "Delete Unit", "delete_unit_description": "Click to delete the nearest unit" @@ -990,7 +924,6 @@ "replay": "Replay", "details": "Details", "ranking": "Ranking", - "started": "Started", "map": "Map", "difficulty": "Difficulty", "type": "Type" @@ -998,7 +931,7 @@ "player_stats_tree": { "public": "Public", "private": "Private", - "singleplayer": "Solo", + "solo": "Solo", "mode": "Mode", "stats_wins": "Wins", "stats_losses": "Losses", diff --git a/src/client/Utils.ts b/src/client/Utils.ts index 57bfed9b7..c4709f945 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -44,12 +44,36 @@ export function getGameModeLabel(gameConfig: GameConfig): string { } } - // Numeric team count + // Numeric team count (e.g. "5 teams of 20") const teamCount = typeof playerTeams === "number" ? playerTeams : getTeamCount(playerTeams, maxPlayers ?? 0); - return translateText("public_lobby.teams", { num: teamCount }); + const teamSize = + teamCount > 0 ? Math.floor((maxPlayers ?? 0) / teamCount) : 0; + + // If the computed team size matches a named format, use that label instead + const namedTeamType = + teamSize === 2 + ? Duos + : teamSize === 3 + ? Trios + : teamSize === 4 + ? Quads + : null; + if (namedTeamType) { + const teamKey = `public_lobby.teams_${namedTeamType}`; + const translated = translateText(teamKey, { team_count: teamCount }); + if (translated !== teamKey) { + return translated; + } + } + + const teamsLabel = translateText("public_lobby.teams", { num: teamCount }); + if (teamSize > 0) { + return `${teamsLabel} ${translateText("public_lobby.players_per_team", { num: teamSize })}`; + } + return teamsLabel; } function getTeamCount( diff --git a/tests/UnusedTranslationKeys.test.ts b/tests/UnusedTranslationKeys.test.ts new file mode 100644 index 000000000..46e396cc6 --- /dev/null +++ b/tests/UnusedTranslationKeys.test.ts @@ -0,0 +1,449 @@ +import fs from "fs"; +import path from "path"; +import ts from "typescript"; + +/** + * Regex patterns for keys that are intentionally generated dynamically. + * This keeps dynamic handling explicit and reviewable. + */ +const DYNAMIC_KEY_PATTERNS: RegExp[] = [ + /^difficulty\.[^.]+$/, + /^map\.[^.]+$/, + /^map_categories\.[^.]+$/, + /^chat\.[^.]+\.[^.]+$/, + /^player_stats_table\.unit\.[^.]+$/, + /^host_modal\.teams_.+$/, + /^public_lobby\.teams_.+$/, + /^team_colors\.[^.]+$/, + /^territory_patterns\.pattern\.[^.]+$/, + /^territory_patterns\.pattern_owned\.[^.]+$/, + /^territory_patterns\.color_palette\.[^.]+$/, + /^build_menu\.desc\.[^.]+$/, + /^unit_type\.[^.]+$/, +]; + +/** + * Keys that are intentionally not expected to be used via translateText. + */ +const IGNORED_UNUSED_KEY_PATTERNS: RegExp[] = [ + /^lang\./, // language metadata, not a UI translation key +]; + +type ScanResult = { + usedKeys: Set; + referencedStaticKeys: Set; + dynamicPrefixes: Set; +}; + +function flattenKeys(obj: Record, prefix = ""): string[] { + const keys: string[] = []; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + keys.push(...flattenKeys(value as Record, fullKey)); + } else { + keys.push(fullKey); + } + } + return keys; +} + +function getAllFiles( + dir: string, + extensions: string[], + /** Tracks visited real paths to guard against symlink cycles. */ + seen: Set = new Set(), +): string[] { + const realDir = fs.realpathSync(dir); + if (seen.has(realDir)) return []; // cycle via directory symlink + seen.add(realDir); + + const results: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...getAllFiles(fullPath, extensions, seen)); + } else if (extensions.some((ext) => entry.name.endsWith(ext))) { + results.push(fullPath); + } + } + return results; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function prefixToRegex(prefix: string): RegExp { + return new RegExp(`^${escapeRegex(prefix)}.+$`); +} + +function isTranslateTextCall(node: ts.CallExpression): boolean { + if (ts.isIdentifier(node.expression)) { + return node.expression.text === "translateText"; + } + if (ts.isPropertyAccessExpression(node.expression)) { + return node.expression.name.text === "translateText"; + } + return false; +} + +function isStringLiteralLike( + node: ts.Node, +): node is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral { + return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node); +} + +function getPlusOperands(expr: ts.Expression): ts.Expression[] { + if ( + ts.isBinaryExpression(expr) && + expr.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + return [...getPlusOperands(expr.left), ...getPlusOperands(expr.right)]; + } + return [expr]; +} + +function getStaticStringFromPlus(expr: ts.Expression): string | null { + const parts = getPlusOperands(expr); + let value = ""; + for (const part of parts) { + if (!isStringLiteralLike(part)) return null; + value += part.text; + } + return value; +} + +function getDynamicPrefixFromPlus(expr: ts.Expression): string | null { + const parts = getPlusOperands(expr); + if (parts.length === 0) return null; + const first = parts[0]; + if (!isStringLiteralLike(first)) return null; + if (parts.every((part) => isStringLiteralLike(part))) return null; + return first.text || null; +} + +function extractDataI18nKeys(content: string): Set { + const keys = new Set(); + const attrRegex = + /data-i18n(?:-title|-alt|-aria-label|-placeholder)?\s*=\s*["']([^"']+)["']/g; + let match: RegExpExecArray | null; + while ((match = attrRegex.exec(content)) !== null) { + keys.add(match[1]); + } + return keys; +} + +function extractTranslationKeyLikeAttrs(content: string): Set { + const keys = new Set(); + const keyLikeAttrRegex = + /\b(?:translationKey|labelKey|disabledKey|titleKey|ariaLabelKey|placeholderKey)\s*=\s*["']([^"']+)["']/g; + let match: RegExpExecArray | null; + while ((match = keyLikeAttrRegex.exec(content)) !== null) { + keys.add(match[1]); + } + return keys; +} + +function isPotentialTranslationKey( + key: string, + rootKeys: Set, + enKeySet: Set, + allowBareRoot = false, +): boolean { + if (enKeySet.has(key)) return true; + if (allowBareRoot && rootKeys.has(key)) return true; + if (!key.includes(".")) return false; + const root = key.split(".")[0]; + return rootKeys.has(root); +} + +function isKeyNamedProperty(name: ts.PropertyName): boolean { + if (ts.isIdentifier(name)) return /key$/i.test(name.text); + if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) { + return /key$/i.test(name.text); + } + return false; +} + +function collectFromExpression( + expression: ts.Expression, + result: ScanResult, + rootKeys: Set, + enKeySet: Set, + allowBareRoot = false, +): void { + if (isStringLiteralLike(expression)) { + if ( + isPotentialTranslationKey( + expression.text, + rootKeys, + enKeySet, + allowBareRoot, + ) + ) { + result.referencedStaticKeys.add(expression.text); + } + return; + } + + if (ts.isTemplateExpression(expression)) { + const prefix = expression.head.text; + if ( + prefix.length > 0 && + /[._]$/.test(prefix) && + isPotentialTranslationKey(prefix, rootKeys, enKeySet) + ) { + result.dynamicPrefixes.add(prefix); + } + return; + } + + if ( + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + const staticValue = getStaticStringFromPlus(expression); + if (staticValue !== null) { + if (isPotentialTranslationKey(staticValue, rootKeys, enKeySet)) { + result.referencedStaticKeys.add(staticValue); + } + return; + } + + const prefix = getDynamicPrefixFromPlus(expression); + if ( + prefix !== null && + /[._]$/.test(prefix) && + isPotentialTranslationKey(prefix, rootKeys, enKeySet) + ) { + result.dynamicPrefixes.add(prefix); + } + return; + } + + if (ts.isParenthesizedExpression(expression)) { + collectFromExpression( + expression.expression, + result, + rootKeys, + enKeySet, + allowBareRoot, + ); + return; + } + + if (ts.isConditionalExpression(expression)) { + collectFromExpression( + expression.whenTrue, + result, + rootKeys, + enKeySet, + allowBareRoot, + ); + collectFromExpression( + expression.whenFalse, + result, + rootKeys, + enKeySet, + allowBareRoot, + ); + return; + } +} + +function scanTsFile( + filePath: string, + rootKeys: Set, + enKeySet: Set, +): ScanResult { + const content = fs.readFileSync(filePath, "utf-8"); + const sourceFile = ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + true, + filePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + + const result: ScanResult = { + usedKeys: new Set(), + referencedStaticKeys: new Set(), + dynamicPrefixes: new Set(), + }; + + const dataI18nKeys = extractDataI18nKeys(content); + for (const key of dataI18nKeys) { + result.referencedStaticKeys.add(key); + } + const keyLikeAttrKeys = extractTranslationKeyLikeAttrs(content); + for (const key of keyLikeAttrKeys) { + result.referencedStaticKeys.add(key); + } + + const visit = (node: ts.Node) => { + // Broad match: any string literal in any .ts/.tsx file that exactly + // matches an en.json key is counted as "used". This is intentionally + // permissive to avoid false-positive "unused" reports, but it means a + // key appearing in an unrelated context (e.g. a log message or object + // key that happens to share the same name) will mask a genuinely + // unused translation key. + if (isStringLiteralLike(node) && enKeySet.has(node.text)) { + result.usedKeys.add(node.text); + } + + if (ts.isCallExpression(node) && isTranslateTextCall(node)) { + const firstArg = node.arguments[0]; + if (firstArg !== undefined) { + collectFromExpression(firstArg, result, rootKeys, enKeySet, true); + } + } + + if ( + ts.isPropertyAssignment(node) && + isKeyNamedProperty(node.name) && + ts.isExpression(node.initializer) + ) { + collectFromExpression(node.initializer, result, rootKeys, enKeySet); + } + + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + /key$/i.test(node.name.text) && + node.initializer !== undefined + ) { + collectFromExpression(node.initializer, result, rootKeys, enKeySet); + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + + for (const key of dataI18nKeys) { + if (enKeySet.has(key)) { + result.usedKeys.add(key); + } + } + for (const key of keyLikeAttrKeys) { + if (enKeySet.has(key)) { + result.usedKeys.add(key); + } + } + + return result; +} + +describe("Unused Translation Keys", () => { + test("en.json keys stay in sync with source usage", () => { + const enJsonPath = path.join(__dirname, "../resources/lang/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 sourceFiles = getAllFiles(srcDir, [".ts", ".tsx", ".js", ".jsx"]); + + const usedKeys = new Set(); + const referencedStaticKeys = new Set(); + const dynamicPrefixes = new Set(); + + 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) + referencedStaticKeys.add(key); + for (const prefix of scan.dynamicPrefixes) dynamicPrefixes.add(prefix); + } + + const indexHtmlPath = path.join(__dirname, "../index.html"); + if (fs.existsSync(indexHtmlPath)) { + const htmlContent = fs.readFileSync(indexHtmlPath, "utf-8"); + const htmlDataI18nKeys = extractDataI18nKeys(htmlContent); + for (const key of htmlDataI18nKeys) { + referencedStaticKeys.add(key); + if (enKeySet.has(key)) { + usedKeys.add(key); + } + } + } + + const derivedDynamicPatterns = Array.from(dynamicPrefixes) + .sort() + .map((prefix) => prefixToRegex(prefix)); + const dynamicKeyPatterns = [ + ...DYNAMIC_KEY_PATTERNS, + ...derivedDynamicPatterns, + ]; + + const unusedKeys: string[] = []; + const dynamicKeys: string[] = []; + const missingKeys: string[] = []; + + // NOTE: The isPotentialTranslationKey check below intentionally skips any + // referenced key whose root namespace (the part before the first ".") is + // not already present in en.json's rootKeys. This means keys under entirely + // new namespaces (e.g. "brand_new_namespace.some_key") will NOT be reported + // as missing. This trade-off was chosen to reduce false-positive noise from + // string literals that look like translation keys but aren't (config keys, + // CSS classes, etc.). It is a known limitation: if a real translation key + // is added under a brand-new namespace and no en.json entry exists yet, + // this test will not catch it. + for (const key of Array.from(referencedStaticKeys).sort()) { + if (enKeySet.has(key)) continue; + if (!isPotentialTranslationKey(key, rootKeys, enKeySet)) continue; + missingKeys.push(key); + } + + for (const key of allKeys) { + if (usedKeys.has(key)) continue; + if (IGNORED_UNUSED_KEY_PATTERNS.some((pattern) => pattern.test(key))) { + continue; + } + + const isDynamic = dynamicKeyPatterns.some((pattern) => pattern.test(key)); + if (isDynamic) { + dynamicKeys.push(key); + } else { + unusedKeys.push(key); + } + } + + const hasFailing = missingKeys.length > 0 || unusedKeys.length > 0; + + if (hasFailing) { + if (derivedDynamicPatterns.length > 0) { + console.log( + `\nDerived dynamic patterns (${derivedDynamicPatterns.length}):\n` + + derivedDynamicPatterns.map((p) => ` ${p.source}`).join("\n"), + ); + } + + if (dynamicKeys.length > 0) { + console.log( + `\nDynamically referenced keys (${dynamicKeys.length}) - verify manually:\n` + + dynamicKeys.map((k) => ` ${k}`).join("\n"), + ); + } + + if (missingKeys.length > 0) { + console.error( + `\nMissing translation keys in en.json (${missingKeys.length}):\n` + + missingKeys.map((k) => ` ${k}`).join("\n"), + ); + } + + if (unusedKeys.length > 0) { + console.error( + `\nUnused translation keys (${unusedKeys.length}):\n` + + unusedKeys.map((k) => ` ${k}`).join("\n"), + ); + } + } + + expect(missingKeys).toEqual([]); + expect(unusedKeys).toEqual([]); + }); +});