Files
retire-planner/tests/ui.smoke.js
T

217 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
/* 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,
value: '', textContent: '', disabled: false,
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; },
appendChild() { },
remove() { },
classList: { add() { }, remove() { }, toggle() { }, contains() { return false; } },
};
Object.defineProperty(el, 'onclick', { set(f) { this._l.click = f; }, get() { return this._l.click; } });
Object.defineProperty(el, 'oninput', { set(f) { this._l.input = f; }, get() { return this._l.input; } });
return el;
}
function q(sel) {
const id = sel.replace(/^#/, '');
if (sel === '.panel-h') return null;
return registry[id] || (registry[id] = makeEl(id));
}
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, psState) {
buildShim();
global.window.RetireEngine = require(path.join(SITE, 'engine', 'engine.js'));
global.window.RetireCanada = require(path.join(SITE, 'engine', 'canada.js'));
global.window.RetirePS = require(path.join(SITE, 'engine', 'pspension.js'));
// persisted state in the target language: fresh answers, dropped at the quiz start
store[LS] = psState ? JSON.stringify(psState) : 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'));
}
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(); };
/* 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);
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 \/ 15/.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))));
// 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');
ok('reached results screen', q('#scr-results').hidden === false);
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('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/,
};
/* PS module flow: federal public servant answers → results card + chart series, per language. */
function runPs(lang, M, done) {
console.log('\n--- ' + lang.toUpperCase() + ' (PS module) ---');
load(lang, {
a: {
province: 'ON', age: 60, retireAge: 60, household: 'single',
income: 0, rrsp: 100000, tfsa: 30000, nonreg: 20000, monthlyContribution: 0,
spendingAnnual: 50000,
psPension: true, psJoinYear: 2000, psServiceYears: 30, psAvgSalary: 100000,
psOccupation: 'it', psItLevel: 'IT-02', psItStep: 1
}, step: 0, screen: 'results', lang: lang });
setTimeout(() => {
const card = q('#ps-card');
ok('PS results card visible', card.hidden === false);
const h = N(card._html);
ok('PS card title localized', M.psCard.test(h), h.slice(0, 70));
ok('PS card pension at 60 (2%×100k×30 = 60,000)', M.psAt.test(h), (h.match(M.psAt) || [''])[0]);
ok('PS card from-65 (lifetime 46,012.50)', M.psFrom.test(h), (h.match(M.psFrom) || [''])[0]);
ok('PS card bridge line (13,987.50)', M.psBridge.test(h), (h.match(M.psBridge) || [''])[0]);
ok('PS card sources link PSPC canada.ca', /href="https:\/\/www\.canada\.ca/.test(h));
ok('PS card IT agreement source (2024-12-22)', /2024-12-22/.test(h));
ok('PS series in G2 chart', M.psSeries.test(N(q('#g2')._html)), N(q('#g2')._html).slice(0, 40));
ok('PS insight in live panel', M.psInsight.test(N(q('#panel-insight')._html)), N(q('#panel-insight')._html).slice(0, 70));
done();
}, 400);
}
run('en', EN, () => run('fr', FR, () => runPs('en', {
psCard: /federal public service pension/, psAt: /60,000/, psFrom: /46,012\.50/,
psBridge: /13,987\.50/, psSeries: /Public service pension/, psInsight: /covers about/
}, () => runPs('fr', {
psCard: /pension de la fonction publique fédérale/, psAt: /60[\s\u00a0\u202f]000/, psFrom: /46[\s\u00a0\u202f]012,50/,
psBridge: /13[\s\u00a0\u202f]987,50/, psSeries: /Pension de la fonction publique/, psInsight: /couvre environ/
}, () => {
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);
}))))
;