// i18n unit tests: dict parity, t() semantics, Intl formatters (EN/FR), // static applyI18n over the real index.html (parsed shim), lang persistence + load precedence. // Run: node tests/i18n.test.js (exits 0 + prints I18N-OK) 'use strict'; const fs = require('fs'), path = require('path'); const SITE = path.join(__dirname, '..', 'site'); const LS = 'retire-planner-v1'; let fails = 0, total = 0; function ok(name, cond, extra) { total++; console.log((cond ? ' PASS ' : ' FAIL ') + name + (extra !== undefined ? ' [' + String(extra).slice(0, 90) + ']' : '')); if (!cond) fails++; } const N = s => String(s == null ? '' : s).replace(/[\u202f\u00a0]/g, ' '); // narrow-nbsp/nbsp → space /* ---------- shim: minimal document that mirrors the real index.html static attrs ---------- */ function parseStatic(html) { const els = { text: [], html: [], attr: [] }; let m; const reT = /<(\w+)[^>]*\sdata-i18n="([^"]+)"[^>]*>/g; while ((m = reT.exec(html))) els.text.push(mk(m[2], 'data-i18n')); const reH = /<(\w+)[^>]*\sdata-i18n-html="([^"]+)"[^>]*>/g; while ((m = reH.exec(html))) els.html.push(mk(m[2], 'data-i18n-html')); const reA = /<(\w+)[^>]*\sdata-i18n-attr="([^"]+)"[^>]*>/g; while ((m = reA.exec(html))) els.attr.push(mk(m[2], 'data-i18n-attr')); return els; } function mk(v, attr) { const attrs = {}; attrs[attr] = v; return { attrs, textContent: '', innerHTML: '', getAttribute(k) { return attrs[k] === undefined ? null : attrs[k]; }, setAttribute(k, v2) { attrs[k] = String(v2); }, }; } function fresh(opts) { opts = opts || {}; const els = parseStatic(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8')); const store = Object.assign({}, opts.store); const langBtns = { 'lang-en': mk('', 'id'), 'lang-fr': mk('', 'id') }; global.document = { title: '', documentElement: { lang: 'en', setAttribute() { } }, querySelector: () => null, querySelectorAll(sel) { if (sel === '[data-i18n]') return els.text; if (sel === '[data-i18n-html]') return els.html; if (sel === '[data-i18n-attr]') return els.attr; return []; }, getElementById: id => langBtns[id] || null, createElement: () => mk('', 'tmp'), addEventListener() { }, }; global.localStorage = { getItem: k => (store[k] === undefined ? null : store[k]), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; global.location = { hash: opts.hash || '', pathname: '/index.html', origin: 'http://x' }; global.history = { replaceState() { } }; Object.defineProperty(global, 'navigator', { value: { language: opts.nav || 'en-CA' }, configurable: true }); global.window = { scrollTo() { }, print() { }, prompt() { } }; global.confirm = () => true; global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); eval(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8')); return { els, store, langBtns }; } /* ---------- 1. load + dict parity ---------- */ let ctx = fresh(); const { I18N, t, getLang, setLang, detectLang, applyI18n, fmtMoney, fmtMoneyK, fmtInt, fmtPct, fmtMoney2 } = global.window; function leafKeys(obj, prefix, out) { for (const k in obj) { const v = obj[k], key = prefix ? prefix + '.' + k : k; if (v && typeof v === 'object') leafKeys(v, key, out); else out.push(key); } return out; } const enK = leafKeys(I18N.en, '', []), frK = leafKeys(I18N.fr, '', []); const enSet = new Set(enK), frSet = new Set(frK); ok('en/fr key parity', enK.every(k => frSet.has(k)) && frK.every(k => enSet.has(k)), 'missing in fr: ' + enK.filter(k => !frSet.has(k)) + ' | extra in fr: ' + frK.filter(k => !enSet.has(k))); const get = (o, key) => key.split('.').reduce((x, p) => (x && typeof x === 'object' ? x[p] : undefined), o); const empty = enK.filter(k => !get(I18N.en, k).trim()).concat(frK.filter(k => !get(I18N.fr, k).trim())); ok('no empty values', empty.length === 0, empty.join(',')); const ph = s => (s.match(/\{\w+\}/g) || []).slice().sort().join(','); const phBad = enK.filter(k => frSet.has(k) && ph(get(I18N.en, k)) !== ph(get(I18N.fr, k))); ok('{placeholder} sets equal en↔fr', phBad.length === 0, phBad.map(k => k + ': ' + ph(get(I18N.en, k)) + ' vs ' + ph(get(I18N.fr, k))).join(' | ')); /* every key used in app.js exists in the dict */ const appSrc = fs.readFileSync(path.join(SITE, 'app.js'), 'utf8'); const used = [...new Set([...appSrc.matchAll(/\bt\(\s*'([^']+)'/g)].map(m => m[1]))]; const miss = used.filter(k => !enSet.has(k)); ok('every t() key in app.js exists (en)', miss.length === 0, miss.join(',')); const missFr = used.filter(k => !frSet.has(k)); ok('every t() key in app.js exists (fr)', missFr.length === 0, missFr.join(',')); ok('app.js uses t() extensively', used.length > 90, used.length + ' keys'); /* every static data-i18n key in index.html resolves */ const staticKeys = [...ctx.els.text.map(e => e.getAttribute('data-i18n')), ...ctx.els.html.map(e => e.getAttribute('data-i18n-html')), ...ctx.els.attr.flatMap(e => e.getAttribute('data-i18n-attr').split(',').map(p => p.split(':')[1].trim()))]; const sMiss = staticKeys.filter(k => t(k) === k); ok('static keys resolve in en', sMiss.length === 0, sMiss.join(',')); /* ---------- 2. t() semantics ---------- */ ok('getLang default en', getLang() === 'en'); ok('t interp', t('q.cppstart.hint', { age: 70, adj: 'Full rate' }) === 'Age 70: Full rate'); ok('t missing key falls back to key', t('nope.nope') === 'nope.nope'); /* ---------- 3. formatters EN ---------- */ ok('fmtMoney en', fmtMoney(959835) === '$959,835', fmtMoney(959835)); ok('fmtMoney en negative', fmtMoney(-32962) === '\u2212$32,962', fmtMoney(-32962)); ok('fmtMoneyK en K', fmtMoneyK(523000) === '$523K', fmtMoneyK(523000)); ok('fmtMoneyK en M', fmtMoneyK(1050000) === '$1.05M', fmtMoneyK(1050000)); ok('fmtMoneyK en rounds', fmtMoneyK(959835) === '$960K', fmtMoneyK(959835)); ok('fmtMoneyK en zero', fmtMoneyK(0) === '$0', fmtMoneyK(0)); ok('fmtInt en', fmtInt(959835) === '959,835', fmtInt(959835)); ok('fmtPct en', N(fmtPct(0.05)) === '5%' && N(fmtPct(0.035, 2)) === '3.5%', N(fmtPct(0.035, 2))); ok('fmtMoney2 en', fmtMoney2(1507.65) === '$1,507.65', fmtMoney2(1507.65)); /* ---------- 4. formatters FR (setLang) ---------- */ setLang('fr'); ok('setLang fr', getLang() === 'fr'); ok('t fr', t('quiz.next') === 'Suivant \u2192', t('quiz.next')); ok('fmtMoney fr', N(fmtMoney(959835)) === '959 835 $', N(fmtMoney(959835))); ok('fmtMoney fr negative', N(fmtMoney(-32962)) === '\u221232 962 $', N(fmtMoney(-32962))); ok('fmtMoneyK fr K', N(fmtMoneyK(523000)) === '523 k$', N(fmtMoneyK(523000))); ok('fmtMoneyK fr M', N(fmtMoneyK(1050000)) === '1,05 M$', N(fmtMoneyK(1050000))); ok('fmtMoneyK fr zero', N(fmtMoneyK(0)) === '0 $', N(fmtMoneyK(0))); ok('fmtInt fr', N(fmtInt(959835)) === '959 835', N(fmtInt(959835))); ok('fmtPct fr', N(fmtPct(0.05)) === '5 %' && N(fmtPct(0.035, 2)) === '3,5 %', N(fmtPct(0.035, 2))); ok('fmtMoney2 fr', N(fmtMoney2(1507.65)) === '1 507,65 $', N(fmtMoney2(1507.65))); ok('t fr interp', t('q.retire.years', { v: 20 }) === '20 ans', t('q.retire.years', { v: 20 })); /* ---------- 5. French typography (frNorm) ---------- */ ok('fr apostrophe is U+2019', /d\u2019argent/.test(I18N.fr.r.depletion), I18N.fr.r.depletion.slice(0, 40)); ok('fr NBSP before %', /6\u00a0%/.test(I18N.fr.intro.lede), N(I18N.fr.intro.lede).slice(0, 40)); ok('fr guillemets NBSP', /\u00ab\u00a0\{name\}\u00a0\u00bb/.test(I18N.fr.g4.best), N(I18N.fr.g4.best)); ok('fr NBSP before ?', /\u00a0\?/.test(I18N.fr.g1.h), N(I18N.fr.g1.h)); /* ---------- 6. static applyI18n (FR active) ---------- */ ok('title fr', /Planificateur de retraite/.test(document.title), document.title); const h1 = ctx.els.text.find(e => e.getAttribute('data-i18n') === 'intro.h1'); ok('static h1 fr', h1 && h1.textContent === I18N.fr.intro.h1, h1 && N(h1.textContent)); const desc = ctx.els.attr.find(e => /content:doc\.desc/.test(e.getAttribute('data-i18n-attr'))); ok('meta description fr', desc && desc.attrs.content === I18N.fr.doc.desc); ok('documentElement.lang fr', document.documentElement.lang === 'fr'); ok('aria-pressed synced', ctx.langBtns['lang-en'].attrs['aria-pressed'] === 'false' && ctx.langBtns['lang-fr'].attrs['aria-pressed'] === 'true'); setLang('en'); ok('static h1 back to en', h1 && h1.textContent === I18N.en.intro.h1 && document.title.startsWith('Retire Planner')); /* ---------- 7. persistence + load precedence ---------- */ setLang('fr'); ok('setLang persists lang into LS merge', JSON.parse(ctx.store[LS]).lang === 'fr'); ctx.store[LS] = JSON.stringify({ a: { age: 50 }, step: 3, screen: 'results' }); setLang('en'); setLang('fr'); const merged = JSON.parse(ctx.store[LS]); ok('merge keeps a/step/screen', merged.a.age === 50 && merged.step === 3 && merged.screen === 'results' && merged.lang === 'fr', JSON.stringify(merged)); function b64(o) { return Buffer.from(JSON.stringify(o)).toString('base64'); } /* NB: fresh() re-evals i18n.js → new closure instance; read exports off global.window each time */ const G = () => global.window; let c2 = fresh({ nav: 'fr-CA' }); ok('detectLang fr-CA → fr', G().detectLang() === 'fr' && G().getLang() === 'fr', G().getLang()); c2 = fresh({ nav: 'en-CA' }); ok('detectLang en-CA → en', G().getLang() === 'en'); c2 = fresh({ store: { [LS]: JSON.stringify({ lang: 'fr' }) }, nav: 'en-CA' }); ok('saved lang beats detection', G().getLang() === 'fr', G().getLang()); c2 = fresh({ store: { [LS]: JSON.stringify({ lang: 'en' }) }, nav: 'fr-CA', hash: '#p=' + b64({ a: {}, step: 0, lang: 'fr' }) }); ok('payload lang beats saved + detect', G().getLang() === 'fr', G().getLang()); c2 = fresh({ store: { [LS]: JSON.stringify({ lang: 'fr' }) }, hash: '#p=' + b64({ a: {}, step: 0 }) }); ok('legacy payload (no lang) → en', G().getLang() === 'en', G().getLang()); c2 = fresh({ store: { [LS]: JSON.stringify({ lang: 'fr' }) }, hash: '#p=' + b64({ a: {}, step: 0, lang: 'en' }) }); ok('payload en beats saved fr', G().getLang() === 'en', G().getLang()); console.log(fails ? '\nI18N-FAIL: ' + fails + ' of ' + total : '\nI18N-OK all i18n checks passed (' + total + ')'); process.exit(fails ? 1 : 0);