Bilingual EN/FR: i18n.js dict+t(), lang state+payload, header toggle, FR copy, Intl formatters; i18n.test.js + bilingual smoke; FR mobile audit evidence + BILINGUAL-REPORT.md

This commit is contained in:
krisf committed 2026-10-09 10:06:55 -04:00
1 parent 65863c0302
commit 5f01dd7ca6
60 files changed
+2788 -241

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
// 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);
+141 -70
View File
@@ -1,10 +1,15 @@
// Headless smoke test for site/app.js — tiny DOM shim, drives the full flow.
// Headless smoke test for site/i18n.js + site/app.js — tiny DOM shim, drives the full flow
// in BOTH languages (EN run on defaults, FR run with lang:'fr' persisted).
// Run: node tests/ui.smoke.js (exits 0 + prints SMOKE-OK)
'use strict';
const fs = require('fs'), path = require('path');
const SITE = path.join(__dirname, '..', 'site');
const LS = 'retire-planner-v1';
const registry = {};
/* normalize narrow-nbsp/nbsp → space (fr-CA Intl + frNorm typography) for regex checks */
const N = s => String(s == null ? '' : s).replace(/[\u202f\u00a0]/g, ' ');
let registry = {};
function makeEl(id) {
const el = {
id, _html: '', _l: {}, style: {}, dataset: {}, hidden: false,
@@ -12,6 +17,7 @@ function makeEl(id) {
get innerHTML() { return this._html; },
set innerHTML(v) { this._html = String(v); },
addEventListener(t, fn) { this._l[t] = fn; },
setAttribute() { }, getAttribute() { return null; },
querySelector() { return makeEl(id + '>q'); },
querySelectorAll() { return []; },
contains() { return false; },
@@ -28,83 +34,148 @@ function q(sel) {
if (sel === '.panel-h') return null;
return registry[id] || (registry[id] = makeEl(id));
}
global.document = {
querySelector: q,
querySelectorAll: () => [],
addEventListener() { },
createElement: (t) => makeEl('el-' + t),
getElementById: (id) => registry[id] || (registry[id] = makeEl(id)), // shim ext: app.js scrim/panel sync (Fix 3)
};
const store = {};
global.localStorage = { getItem: k => store[k] || null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } };
global.location = { hash: '', pathname: '/index.html', origin: 'http://x' };
global.history = { replaceState() { } };
Object.defineProperty(global, 'navigator', { value: {}, configurable: true });
global.window = { scrollTo() { }, print() { }, prompt() { } }; // prompt stub: Fix 7 share fallback
global.confirm = () => true;
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
let store = {};
let createdEls = []; // shim ext: createElement tracking (validate() appends its .err div this way)
function buildShim() {
registry = {}; store = {}; createdEls = [];
global.document = {
title: '',
querySelector: q,
querySelectorAll: () => [],
addEventListener() { },
createElement: (t) => { const e = makeEl('el-' + t); createdEls.push(e); return e; },
getElementById: (id) => registry[id] || (registry[id] = makeEl(id)), // shim ext: app.js scrim/panel sync (Fix 3) + i18n lang buttons
documentElement: { style: { setProperty() { } }, setAttribute() { }, lang: 'en' }, // i18n applyI18n + app syncSheetH
};
global.localStorage = { getItem: k => store[k] || null, setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } };
global.location = { hash: '', pathname: '/index.html', origin: 'http://x' };
global.history = { replaceState() { } };
Object.defineProperty(global, 'navigator', { value: { language: 'en-CA' }, configurable: true });
global.window = { scrollTo() { }, print() { }, prompt() { } }; // prompt stub: Fix 7 share fallback
global.confirm = () => true;
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
function load(lang) {
buildShim();
global.window.RetireEngine = require(path.join(SITE, 'engine', 'engine.js'));
global.window.RetireCanada = require(path.join(SITE, 'engine', 'canada.js'));
// persisted state in the target language: fresh answers, dropped at the quiz start
store[LS] = JSON.stringify({ a: {}, step: 0, screen: 'quiz', lang: lang });
eval(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8'));
eval(fs.readFileSync(path.join(SITE, 'app.js'), 'utf8'));
}
// load engine + canada + app in order (classic scripts sharing globals)
const engP = path.join(SITE, 'engine', 'engine.js'), canP = path.join(SITE, 'engine', 'canada.js'), appP = path.join(SITE, 'app.js');
global.window.RetireEngine = require(engP);
global.window.RetireCanada = require(canP);
eval(fs.readFileSync(appP, 'utf8'));
// ---- drive the UI ----
let fails = 0;
let fails = 0, total = 0;
function ok(name, cond, extra) {
total++;
console.log((cond ? ' PASS ' : ' FAIL ') + name + (extra !== undefined ? ' [' + String(extra).slice(0, 80) + ']' : ''));
if (!cond) fails++;
}
const click = id => { const el = q('#' + id); if (!el._l.click) throw new Error('no click handler on #' + id); el._l.click(); };
ok('intro wiring: begin button has handler', !!q('#btn-begin')._l.click);
click('btn-begin');
ok('quiz screen shown, step 1 = province', /Where do you live/.test(q('#step-body')._html));
/* drive the full quiz → results flow for one language, then run per-language checks. */
function run(lang, M, done) {
console.log('\n--- ' + lang.toUpperCase() + ' ---');
load(lang);
// province grid: app attaches onclick to opts via querySelectorAll (shim returns [])
// → set province through state by faking a click on the first opt is impossible;
// simulate by driving Next: province unset → Next disabled.
ok('province step requires a pick (Next disabled)', q('#btn-next').disabled === true);
// emulate the option click path: app.js assigns onclick to elements from querySelectorAll;
// give it one opt for the current render:
const opt = makeEl('opt'); opt.dataset.v = 'ON'; q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []);
click('btn-begin'); // re-render step with patched querySelectorAll
opt._l.click();
ok('province ON selectable', registry.__probe !== 'x' && !/Pick a province/.test(q('#step-body')._html));
ok('intro wiring: begin button has handler', !!q('#btn-begin')._l.click);
click('btn-begin');
ok('step 1 title', M.title.test(q('#step-body')._html), N(q('#step-body')._html).slice(0, 60));
ok('step count', /1 \/ 14/.test(q('#step-count').textContent), q('#step-count').textContent);
ok('province step requires a pick (Next disabled)', q('#btn-next').disabled === true);
ok('validation message', createdEls.some(e => M.needProvince.test(N(e.textContent))));
// walk all steps
let guard = 0;
while (!q('#btn-next').disabled && guard++ < 40) {
const h = q('#step-body')._html;
if (/runs out|gap|vision/i.test(h) && /See my plan/.test(q('#btn-next').textContent)) break;
// emulate the option click path (shim querySelectorAll patched to hand back one opt)
const opt = makeEl('opt'); opt.dataset.v = 'ON';
q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []);
click('btn-begin'); // re-render step with patched querySelectorAll
opt._l.click();
ok('province ON selectable', q('#btn-next').disabled === false);
// walk all steps (original gating: disabled reflects step validity)
let guard = 0, sawCpp = false, sawSeePlan = false;
while (guard++ < 40 && !q('#btn-next').disabled) {
const h = N(q('#step-body')._html);
if (M.cppStep.test(h)) sawCpp = true;
if (M.seePlan.test(q('#btn-next').textContent)) { sawSeePlan = true; break; }
click('btn-next');
if (q('#scr-results').hidden === false) break;
}
ok('reached last step (See my plan / Voir mon plan)', sawSeePlan, 'steps=' + guard);
ok('CPP start step seen', sawCpp);
click('btn-next');
if (/Retirement Vision|vision/i.test(q('#scr-results')._html)) break;
if (q('#scr-intro').hidden === false) break;
// results render flips hidden
if (q('#scr-results').hidden === false) break;
}
ok('reached results screen', q('#scr-results').hidden === false, 'steps=' + guard);
ok('verdict rendered', /money|sustain|runs out/i.test(q('#verdict')._html));
ok('gap cards rendered', /gcard/.test(q('#gap-cards')._html));
ok('G1 balance chart is inline SVG', /<svg/.test(q('#g1')._html));
ok('G2 income chart is inline SVG', /<svg/.test(q('#g2')._html));
ok('G3 cash chart is inline SVG', /<svg/.test(q('#g3')._html));
ok('year table has rows', (q('#yr-table')._html.match(/<tr/g) || []).length > 20);
ok('stamp has 2026 + CPP max', /tax rates 2026/.test(q('#stamp').textContent) && /1,507\.65/.test(q('#stamp').textContent), q('#stamp').textContent.slice(0, 60));
ok('vision card rendered', /Retirement Vision/.test(q('#vision-card')._html));
ok('privacy line present in HTML', /never leave your browser/.test(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8')));
ok('reached results screen', q('#scr-results').hidden === false);
// G4 async (setTimeout 30ms)
setTimeout(() => {
ok('G4 scenario bars rendered', /<svg/.test(q('#g4')._html), (q('#g4')._html || '').slice(0, 40));
// what-if slider re-render
const wi = q('#wi-spend'); wi.value = '95000';
wi._l.input && wi._l.input.call(wi);
const verdict = N(q('#verdict')._html);
ok('verdict rendered', M.verdict.test(verdict), verdict.slice(0, 70));
ok('gap cards rendered', /gcard/.test(q('#gap-cards')._html));
ok('G1 chart is inline SVG', /<svg/.test(q('#g1')._html));
ok('G1 title localized', M.g1Title.test(N(q('#g1')._html)), N(q('#g1')._html).slice(0, 50));
ok('G2 chart is inline SVG', /<svg/.test(q('#g2')._html));
ok('G2 legend localized', M.g2Legend.test(q('#g2')._html));
ok('G3 chart is inline SVG', /<svg/.test(q('#g3')._html));
ok('year table has rows', (q('#yr-table')._html.match(/<tr/g) || []).length > 20);
ok('year table header localized', M.tblHeader.test(q('#yr-table')._html));
ok('year summary localized', M.yrSummary.test(N(q('#yr-summary')._html)), N(q('#yr-summary')._html));
const stamp = N(q('#stamp').textContent);
ok('stamp has 2026 + CPP max (locale format)', M.stamp2026.test(stamp) && M.stampCpp.test(stamp), stamp.slice(0, 80));
ok('vision card rendered', M.vision.test(q('#vision-card')._html));
ok('document.title localized', M.docTitle.test(N(document.title)), document.title.slice(0, 60));
// G4 async (setTimeout 30ms) + live panel (120ms)
setTimeout(() => {
ok('what-if slider updates verdict (spending 95k)', /95/.test(q('#verdict')._html + q('#wi-spend-o').textContent), q('#wi-spend-o').textContent);
ok('localStorage saved', !!store['retire-planner-v1']);
console.log(fails ? '\nSMOKE-FAIL: ' + fails : '\nSMOKE-OK all ui smoke checks passed');
process.exit(fails ? 1 : 0);
}, 500);
}, 250);
ok('G4 scenario bars rendered', /<svg/.test(q('#g4')._html) && M.g4Scen.test(q('#g4')._html), N(q('#g4')._html).slice(0, 60));
ok('live panel chips localized', M.panel.test(N(q('#panel-chips')._html)), N(q('#panel-chips')._html).slice(0, 70));
// what-if slider re-render
const wi = q('#wi-spend'); wi.value = '95000';
wi._l.input && wi._l.input.call(wi);
setTimeout(() => {
ok('what-if slider updates spending label (95k)', /95/.test(N(q('#wi-spend-o').textContent)), N(q('#wi-spend-o').textContent));
ok('wi label localized', M.wiCtrb.test(N(q('#wi-ctrb-o').textContent)), N(q('#wi-ctrb-o').textContent));
ok('localStorage saved with lang', JSON.parse(store[LS]).lang === lang);
done();
}, 500);
}, 250);
}
const EN = {
title: /Where do you live/,
docTitle: /Retire Planner/,
needProvince: /Pick a province/,
seePlan: /See my plan/,
cppStep: /Start CPP/,
verdict: /money|sustain|runs out/,
g1Title: /Will it last/,
g2Legend: /From savings/,
tblHeader: /Withdraw/,
yrSummary: /Year-by-year detail \(\d+ years\)/,
stamp2026: /tax rates 2026/, stampCpp: /1,507\.65/,
vision: /Retirement Vision/,
g4Scen: /Current plan/,
panel: /Savings at retirement/,
wiCtrb: /\/mo/,
};
const FR = {
title: /Où habitez-vous/,
docTitle: /Planificateur de retraite/,
needProvince: /Choisissez une province/,
seePlan: /Voir mon plan/,
cppStep: /Débuter la RPC/,
verdict: /manquez d.argent|dépenser en toute sûreté|tient jusqu/,
g1Title: /épargne tiendra-t-elle/,
g2Legend: /Épargne/,
tblHeader: /Retrait/,
yrSummary: /Détail année par année \(\d+ ans\)/,
stamp2026: /barèmes 2026/, stampCpp: /1 507,65/,
vision: /Vision de retraite/,
g4Scen: /Plan actuel/,
panel: /Épargne à la retraite/,
wiCtrb: /\/mois/,
};
run('en', EN, () => run('fr', FR, () => {
ok('EN privacy line present in index.html', /never leave your browser/.test(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8')));
ok('FR privacy string present in i18n.js', /ne quittent jamais votre navigateur/.test(fs.readFileSync(path.join(SITE, 'i18n.js'), 'utf8')));
console.log(fails ? '\nSMOKE-FAIL: ' + fails + ' of ' + total : '\nSMOKE-OK all ui smoke checks passed (' + total + ')');
process.exit(fails ? 1 : 0);
}));