427 lines
24 KiB
JavaScript
427 lines
24 KiB
JavaScript
// 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);
|
||
/* shim ext (PS-AUTO-SERVICE): a real DOM creates a fresh <input> with the attribute value
|
||
on every innerHTML write; the registry reuses one #f1 element, so its stale .value let a
|
||
slider's initial oninput() wipe fields (retireAge → 0) and poisoned later slider steps.
|
||
Sync #f1.value from the rendered value="…" attribute, exactly like the browser. */
|
||
if (id === 'step-body' && typeof registry !== 'undefined' && registry.f1) {
|
||
const m = /<input[^>]*id="f1"[^>]*value="([^"]*)"/.exec(this._html);
|
||
registry.f1.value = m ? m[1] : '';
|
||
}
|
||
},
|
||
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, patch) {
|
||
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 });
|
||
if (patch) patch(global.window); /* fixture hook: wrap engine before app.js eval (e.g. mixed-row †) */
|
||
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/,
|
||
retireStep: /Retirement age/,
|
||
fireHint: /Early retirement \(FIRE\) supported/,
|
||
mixedNote: /One partner is retired/,
|
||
};
|
||
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/,
|
||
retireStep: /Âge de la retraite/,
|
||
fireHint: /retraite anticipée \(FIRE\)/,
|
||
mixedNote: /partenaire est à la retraite/,
|
||
};
|
||
|
||
/* 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);
|
||
ok('legacy saved psServiceYears → psServiceOverride (pre-auto plans keep their exact numbers)',
|
||
JSON.parse(store[LS]).a.psServiceOverride === 30, JSON.stringify(JSON.parse(store[LS]).a.psServiceOverride));
|
||
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 card table uses non-colliding class ps-tbl (grid-on-table would blank the card)',
|
||
/<table class="ps-tbl">/.test(h) && (h.match(/<tr>/g) || []).length >= 7,
|
||
'rows=' + (h.match(/<tr>/g) || []).length);
|
||
ok('as-of line localized (' + lang + ')', lang === 'fr' ? (!/October|Oct-Dec/.test(h) && /octobre 2026/.test(h)) : /October 2026/.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);
|
||
}
|
||
|
||
/* PS module quiz click-through — regression test (2026-10-09): typing into an initially-empty
|
||
PS number step (join year / service / salary) MUST re-enable Next. Pre-fix, renderStep validated
|
||
only at render, so the button stayed disabled and a real user was STUCK on the join step.
|
||
PS-AUTO-SERVICE (2026-10-09): the ps_service step now prefills itself from age + join year +
|
||
retireAge (defaults: age 45, joined 2005 → joined at 24, retire 65 → auto 41). Walk checks:
|
||
auto prefill + derivation hint + summary use the auto value; a typed override (30) drives the
|
||
summary instead; "Use calculated" restores the auto summary. EN + FR. */
|
||
function runPsQuiz(lang, done) {
|
||
console.log('\n--- ' + lang.toUpperCase() + ' (PS quiz click-through) ---');
|
||
load(lang);
|
||
setTimeout(() => {
|
||
const opt = makeEl('opt'); opt.dataset.v = 'ON';
|
||
q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []);
|
||
click('btn-begin'); // re-render step 0 with patched querySelectorAll
|
||
const body = () => N(q('#step-body').innerHTML);
|
||
const typed = {};
|
||
let guard = 0, joinDisabledBefore = null;
|
||
while (guard++ < 40) {
|
||
const st = JSON.parse(store[LS]);
|
||
const step = st.step;
|
||
const next = q('#btn-next');
|
||
if (step === 0 && !typed.prov) { opt.dataset.v = 'ON'; opt._l.click(); typed.prov = 1; continue; }
|
||
if (step === 11 && !typed.ps) { typed.ps = 1; opt.dataset.v = 'yes'; opt._l.click();
|
||
ok('PS toggle: Yes picked', JSON.parse(store[LS]).a.psPension === true); continue; }
|
||
if (step === 12 && !typed.join) { typed.join = 1; joinDisabledBefore = next.disabled;
|
||
const f1 = q('#f1'); f1.value = '2005'; f1._l.input && f1._l.input.call(f1);
|
||
ok('PS join step starts disabled (empty field)', joinDisabledBefore === true, 'disabled=' + joinDisabledBefore);
|
||
ok('typing into empty PS step enables Next (regression: validate on input)', q('#btn-next').disabled === false, 'after=' + q('#btn-next').disabled);
|
||
continue; }
|
||
if (step === 13 && !typed.auto) {
|
||
typed.auto = 1;
|
||
/* PS-AUTO-SERVICE: NO typing here — auto = 65 − (45 − (2026 − 2005)) = 41 */
|
||
ok('ps_service prefilled with the auto value 41 (joined 2005 at 24, retire 65) — no typing',
|
||
/value="41"/.test(body()));
|
||
ok('ps_service auto hint shows the derivation',
|
||
lang === 'en' ? /Calculated: joined 2005 at age 24, retiring at 65 → 41 years of service/.test(body())
|
||
: /Calculé : entrée en poste en 2005 à 24 ans, départ à 65 ans → 41 années de service/.test(body()),
|
||
(body().match(/<p class="hint">[^<]*/) || [''])[0]);
|
||
ok('ps_service Next enabled from the auto value, override stays null',
|
||
q('#btn-next').disabled === false && JSON.parse(store[LS]).a.psServiceOverride == null,
|
||
'disabled=' + q('#btn-next').disabled);
|
||
ok('reset button in DOM but HIDDEN in auto mode',
|
||
/id="ps-svc-reset"[^>]*display:none/.test(body()) && /id="ps-usecalc"/.test(body()));
|
||
continue;
|
||
}
|
||
if (step === 14 && !typed.sal) { typed.sal = 1; const f1 = q('#f1'); f1.value = '90000'; f1._l.input && f1._l.input.call(f1); continue; }
|
||
if (step === 15 && !typed.occ) { typed.occ = 1; opt.dataset.v = 'it'; opt._l.click(); continue; }
|
||
if (step === 16) {
|
||
const a2 = JSON.parse(store[LS]).a;
|
||
if (!a2.psItLevel) { opt.dataset.v = 'IT-03'; opt._l.click(); continue; }
|
||
if (!a2.psItStep) { opt.dataset.v = '3'; opt._l.click(); continue; }
|
||
}
|
||
if (next.disabled) break;
|
||
click('btn-next');
|
||
if (q('#scr-results').hidden === false) break;
|
||
}
|
||
ok('PS click-through reached results', q('#scr-results').hidden === false, 'guard=' + guard);
|
||
ok('PS card rendered after click-through', !!q('#ps-card') && q('#ps-card').hidden === false);
|
||
const cardA = N(q('#ps-card').innerHTML);
|
||
ok('auto service flows to the summary: 35 years used (auto 41, engine cap 35)',
|
||
lang === 'en' ? /35 years \(max 35\)/.test(cardA) : /35 années \(max 35\)/.test(cardA), cardA.slice(0, 160));
|
||
ok('auto summary amount (S 35, avg 108,324, G1 @65): from-65 59,508.05',
|
||
lang === 'en' ? /59,508\.05/.test(cardA) : /59[\s\u00a0\u202f]508,05/.test(cardA), cardA.slice(0, 160));
|
||
setTimeout(() => {
|
||
/* back to ps_service via the REAL route (results → Edit answers → 6× Back), type an override */
|
||
click('btn-edit');
|
||
for (let i = 0; i < 6; i++) click('btn-back');
|
||
let st = JSON.parse(store[LS]);
|
||
ok('back-navigation lands on ps_service (step 13)', st.step === 13, 'step=' + st.step);
|
||
ok('ps_service shows the auto value again (override null)', /value="41"/.test(body()) && st.a.psServiceOverride == null);
|
||
const f1b = q('#f1'); f1b.value = '30'; f1b._l.input && f1b._l.input.call(f1b);
|
||
click('btn-back'); click('btn-next'); // re-render ps_service with the override in place
|
||
st = JSON.parse(store[LS]);
|
||
ok('override typed → psServiceOverride = 30 (the value the engine must use)',
|
||
st.a.psServiceOverride === 30, JSON.stringify(st.a.psServiceOverride));
|
||
const bodyO = body();
|
||
ok('override hint states the override + the auto value',
|
||
lang === 'en' ? /Using your value \(30 years\).*gives 41 years/.test(bodyO)
|
||
: /Votre valeur \(30 années\) est utilisée.*donne 41 ans/.test(bodyO),
|
||
(bodyO.match(/<p class="hint">[^<]*/) || [''])[0]);
|
||
ok('"Use calculated" button VISIBLE (not hidden) while overridden',
|
||
/id="ps-svc-reset" style="margin:8px 0 0">/.test(bodyO) && /id="ps-usecalc"/.test(bodyO),
|
||
(bodyO.match(/id="ps-svc-reset"[^>]*>/) || [''])[0]);
|
||
let g2 = 0;
|
||
while (g2++ < 12 && q('#scr-results').hidden !== false) {
|
||
if (q('#btn-next').disabled) break;
|
||
click('btn-next');
|
||
}
|
||
const cardB = N(q('#ps-card').innerHTML);
|
||
ok('override flows to the summary: 30 years used',
|
||
lang === 'en' ? /30 years \(max 35\)/.test(cardB) : /30 années \(max 35\)/.test(cardB), cardB.slice(0, 160));
|
||
ok('override summary amount (S 30): from-65 51,006.90',
|
||
lang === 'en' ? /51,006\.90/.test(cardB) : /51[\s\u00a0\u202f]006,90/.test(cardB), cardB.slice(0, 160));
|
||
setTimeout(() => {
|
||
click('btn-edit');
|
||
for (let i = 0; i < 6; i++) click('btn-back');
|
||
ok('back on ps_service with the override (button shown)',
|
||
JSON.parse(store[LS]).step === 13 && /id="ps-svc-reset" style="margin:8px 0 0">/.test(body()));
|
||
const uc = q('#ps-usecalc');
|
||
ok('"Use calculated" handler wired', !!uc && !!uc._l.click);
|
||
uc._l.click.call(uc);
|
||
st = JSON.parse(store[LS]);
|
||
ok('"Use calculated" clears the override → auto again', st.a.psServiceOverride === null, JSON.stringify(st.a.psServiceOverride));
|
||
ok('auto value prefilled + auto hint restored',
|
||
/value="41"/.test(body()) && (lang === 'en' ? /Calculated: joined 2005/.test(body()) : /Calculé : entrée en poste en 2005/.test(body())));
|
||
let g3 = 0;
|
||
while (g3++ < 12 && q('#scr-results').hidden !== false) {
|
||
if (q('#btn-next').disabled) break;
|
||
click('btn-next');
|
||
}
|
||
const cardC = N(q('#ps-card').innerHTML);
|
||
ok('use-calc restores the auto summary (35 years · 59,508.05)',
|
||
(lang === 'en' ? /35 years \(max 35\)/.test(cardC) : /35 années \(max 35\)/.test(cardC)) &&
|
||
(lang === 'en' ? /59,508\.05/.test(cardC) : /59[\s\u00a0\u202f]508,05/.test(cardC)), cardC.slice(0, 160));
|
||
const a = JSON.parse(store[LS]).a;
|
||
ok('PS state persisted (join 2005 / no override / IT-03 step 3, salary 108,324)',
|
||
a.psPension === true && a.psJoinYear === 2005 && a.psServiceOverride === null &&
|
||
a.psOccupation === 'it' && a.psItLevel === 'IT-03' && a.psItStep === 3 && a.psAvgSalary === 108324,
|
||
JSON.stringify({ j: a.psJoinYear, o: a.psOccupation, l: a.psItLevel, s: a.psItStep, sal: a.psAvgSalary }));
|
||
done();
|
||
}, 400);
|
||
}, 400);
|
||
}, 400);
|
||
}
|
||
|
||
/* 2026-10 math fix (FIRE): quiz retire step — slider floor 40 + FIRE hint, per language. */
|
||
function runFire(lang, M, done) {
|
||
console.log('\n--- ' + lang.toUpperCase() + ' (FIRE: retire age 40) ---');
|
||
load(lang);
|
||
click('btn-begin');
|
||
const opt = makeEl('opt'); opt.dataset.v = 'ON';
|
||
q('#step-body').querySelectorAll = (sel) => (sel === '.opt' ? [opt] : []);
|
||
click('btn-begin');
|
||
opt._l.click();
|
||
let guard = 0;
|
||
while (guard++ < 20) {
|
||
if (M.retireStep.test(N(q('#step-body')._html))) break;
|
||
if (q('#btn-next').disabled) break;
|
||
click('btn-next');
|
||
}
|
||
const h = N(q('#step-body')._html);
|
||
ok('retire step reached', M.retireStep.test(h), 'steps=' + guard);
|
||
ok('FIRE: quiz retire slider min=40', /min="40"/.test(h), (h.match(/<input type="range"[^>]*>/) || [''])[0]);
|
||
ok('FIRE: hint mentions FIRE support', M.fireHint.test(h), h.slice(0, 90));
|
||
ok('FIRE: slider value renders (fmt years)', /id="sv"/.test(h));
|
||
done();
|
||
}
|
||
|
||
/* 2026-10 math fix (couple mixed rows): † on Total cell + bilingual legend under the table.
|
||
The engine keys couple rows by shared AGE and both partners share retireAge, so real mixed
|
||
draw windows need divergent phases — fixture patches project() to flag 3 draw rows. */
|
||
function runMixed(lang, M, done) {
|
||
console.log('\n--- ' + lang.toUpperCase() + ' (couple mixed-row † legend) ---');
|
||
/* engine module is require-cached: wrap and RESTORE project() so the control load is clean */
|
||
const E0 = require(path.join(SITE, 'engine', 'engine.js')), orig = E0.project;
|
||
load(lang, {
|
||
a: {
|
||
province: 'ON', age: 52, household: 'couple', income: 60000, rrsp: 100000, tfsa: 30000,
|
||
nonreg: 20000, monthlyContribution: 500, retireAge: 55, spendingAnnual: 93000, cppStartAge: 60,
|
||
partner: { age: 50, income: 0, rrsp: 20000, tfsa: 10000, nonreg: 5000, monthlyContribution: 200 }
|
||
}, step: 0, screen: 'results', lang: lang
|
||
}, function (w) {
|
||
w.RetireEngine.project = function (p) {
|
||
const r = orig(p);
|
||
r.years.filter(y => y.phase === 'draw').slice(0, 3).forEach(y => { y.mixed = true; });
|
||
return r;
|
||
};
|
||
});
|
||
const tblHtml = q('#yr-table')._html;
|
||
ok('mixed rows footnoted † on Total cell (3 flagged)', (tblHtml.match(/†/g) || []).length === 3, (tblHtml.match(/†/g) || []).length);
|
||
ok('† sits inside the Total <b> cell', /<b>[^<]*\s†<\/b>/.test(tblHtml), (tblHtml.match(/<b>[^<]*†[^<]*<\/b>/) || [''])[0]);
|
||
const note = q('#yr-mixed-note');
|
||
ok('bilingual legend shown under the table', note.hidden === false && M.mixedNote.test(N(note.textContent)), N(note.textContent).slice(0, 60));
|
||
/* control: single-person results have no † and the legend stays hidden */
|
||
E0.project = orig;
|
||
load(lang, {
|
||
a: { province: 'ON', age: 45, household: 'single', income: 60000, rrsp: 100000, tfsa: 30000,
|
||
nonreg: 20000, monthlyContribution: 500, retireAge: 65, spendingAnnual: 50000 },
|
||
step: 0, screen: 'results', lang: lang });
|
||
ok('control single: no † rows, legend hidden', !/†/.test(q('#yr-table')._html) && q('#yr-mixed-note').hidden === true);
|
||
done();
|
||
}
|
||
|
||
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/
|
||
}, () => runPsQuiz('en', () => runPsQuiz('fr', () =>
|
||
runFire('en', EN, () => runFire('fr', FR, () => runMixed('en', EN, () => runMixed('fr', FR, () => {
|
||
const idx = fs.readFileSync(path.join(SITE, 'index.html'), 'utf8');
|
||
ok('what-if retirement slider min 40 (index.html)', /id="wi-ret" min="40"/.test(idx), (idx.match(/id="wi-ret"[^>]*/) || [''])[0]);
|
||
ok('legend element present under yearly table (index.html)', /id="yr-mixed-note"/.test(idx));
|
||
ok('EN privacy line present in index.html', /never leave your browser/.test(idx));
|
||
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);
|
||
}))))))))))
|
||
;
|