i18n-keys.test.js
1 /** 2 * i18n Translation Key Tests 3 * 4 * Verifies that: 5 * 1. en.json (master) contains all required keys with non-empty values 6 * 2. Every other lang file has valid JSON and no raw key strings as values 7 * 3. Keys that MUST be translated in each lang (not just English fallback) are present 8 * 4. No lang file introduces keys absent from en.json (orphan keys) 9 */ 10 11 import { test, describe } from 'node:test'; 12 import assert from 'node:assert/strict'; 13 import { readFileSync, readdirSync } from 'fs'; 14 import { join, dirname } from 'path'; 15 import { fileURLToPath } from 'url'; 16 17 const __dirname = dirname(fileURLToPath(import.meta.url)); 18 const LANG_DIR = join(__dirname, '../../../mmo-platform/auditandfix.com/lang'); 19 // Derived from the actual lang/ directory — automatically picks up future translations 20 const SUPPORTED_LANGS = readdirSync(LANG_DIR) 21 .filter(f => f.endsWith('.json')) 22 .map(f => f.replace('.json', '')) 23 .sort(); 24 25 function loadLang(lang) { 26 return JSON.parse(readFileSync(join(LANG_DIR, `${lang}.json`), 'utf-8')); 27 } 28 29 const en = loadLang('en'); 30 31 // Keys that must exist and be non-empty in en.json (all keys are required in master) 32 const ALL_KEYS = Object.keys(en); 33 34 // Keys where per-language translation is important enough to warn if missing 35 // (locale-specific: phone placeholders, currency labels, price-sensitive copy) 36 const LOCALE_SENSITIVE_KEYS = [ 37 'order.phone_placeholder', 38 'order.currency_hint', 39 'order.price_meta', 40 ]; 41 42 describe('en.json — master translation file', () => { 43 test('parses as valid JSON', () => { 44 assert.ok(typeof en === 'object' && en !== null); 45 }); 46 47 test('contains order.country_label', () => { 48 assert.ok('order.country_label' in en, 'order.country_label must exist in en.json'); 49 assert.ok(en['order.country_label'].length > 0, 'order.country_label must not be empty'); 50 }); 51 52 test('order.country_label is not a raw key string', () => { 53 assert.notEqual(en['order.country_label'], 'order.country_label'); 54 }); 55 56 test('all order.* keys have non-empty string values', () => { 57 const orderKeys = ALL_KEYS.filter(k => k.startsWith('order.')); 58 for (const key of orderKeys) { 59 assert.ok(typeof en[key] === 'string', `en.json key "${key}" must be a string`); 60 assert.ok(en[key].trim().length > 0, `en.json key "${key}" must not be empty`); 61 } 62 }); 63 64 test('no duplicate keys (JSON parse would silently keep last)', () => { 65 // JSON.parse silently deduplicates — count occurrences in raw text instead 66 const raw = readFileSync(join(LANG_DIR, 'en.json'), 'utf-8'); 67 const keyMatches = [...raw.matchAll(/"([^"]+)":/g)].map(m => m[1]); 68 const counts = {}; 69 for (const k of keyMatches) counts[k] = (counts[k] || 0) + 1; 70 const dupes = Object.entries(counts) 71 .filter(([, n]) => n > 1) 72 .map(([k]) => k); 73 assert.equal(dupes.length, 0, `Duplicate keys in en.json: ${dupes.join(', ')}`); 74 }); 75 }); 76 77 describe('All lang files — structural validity', () => { 78 for (const lang of SUPPORTED_LANGS) { 79 test(`${lang}.json parses as valid JSON`, () => { 80 assert.doesNotThrow(() => loadLang(lang), `${lang}.json must be valid JSON`); 81 }); 82 83 test(`${lang}.json has no orphan keys (keys absent from en.json)`, () => { 84 if (lang === 'en') return; 85 const translations = loadLang(lang); 86 const orphans = Object.keys(translations).filter(k => !(k in en)); 87 assert.equal( 88 orphans.length, 89 0, 90 `${lang}.json has keys not in en.json (orphans): ${orphans.join(', ')}` 91 ); 92 }); 93 94 test(`${lang}.json values are non-empty strings where present`, () => { 95 const translations = loadLang(lang); 96 // Only check keys that are non-empty strings in en.json (skip _meta object, 97 // and intentionally-empty keys like props.title / deal.expired) 98 const requiredNonEmpty = Object.keys(en).filter( 99 k => typeof en[k] === 'string' && en[k].trim().length > 0 100 ); 101 const bad = Object.entries(translations) 102 .filter(([k]) => requiredNonEmpty.includes(k)) 103 .filter(([, v]) => typeof v !== 'string' || v.trim().length === 0); 104 assert.equal( 105 bad.length, 106 0, 107 `${lang}.json has empty/non-string values for required keys: ${bad.map(([k]) => k).join(', ')}` 108 ); 109 }); 110 111 test(`${lang}.json values are not raw key strings`, () => { 112 const translations = loadLang(lang); 113 const raw = Object.entries(translations).filter(([k, v]) => k === v); 114 assert.equal( 115 raw.length, 116 0, 117 `${lang}.json has values equal to their own key (unfilled): ${raw.map(([k]) => k).join(', ')}` 118 ); 119 }); 120 } 121 }); 122 123 describe('i18n fallback — order.country_label resolves for all langs', () => { 124 // Simulates the PHP t() function: lang-specific value, falling back to en 125 function t(lang, key) { 126 const translations = lang === 'en' ? en : { ...en, ...loadLang(lang) }; 127 return translations[key] ?? key; 128 } 129 130 for (const lang of SUPPORTED_LANGS) { 131 test(`${lang}: order.country_label resolves to a real string (not the key)`, () => { 132 const value = t(lang, 'order.country_label'); 133 assert.notEqual( 134 value, 135 'order.country_label', 136 `${lang} returned raw key for order.country_label` 137 ); 138 assert.ok(value.length > 0, `${lang} returned empty string for order.country_label`); 139 }); 140 } 141 }); 142 143 describe('i18n file coverage', () => { 144 test('en.json is present (master file required)', () => { 145 assert.ok(SUPPORTED_LANGS.includes('en'), 'en.json must exist in lang/'); 146 }); 147 148 test('at least 2 language files exist', () => { 149 assert.ok( 150 SUPPORTED_LANGS.length >= 2, 151 `Expected ≥2 lang files, found ${SUPPORTED_LANGS.length}` 152 ); 153 }); 154 });