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:
1 parent
1b194b6f92
commit
ea953ec23c
60 files changed
+2788
-241
No files matched your search
+141
-70
@@ -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);
|
||||
}));
|
||||
Reference in new issue
Block a user