engine: GIS 65+ floor, clawback only with OAS, couple mixed-row footnote, FIRE retire age 40

This commit is contained in:
krisf committed 2026-10-09 15:52:53 -04:00
1 parent de451f0525
commit a210e7edfc
10 files changed
+348 -24

No files matched your search

+73 -4
View File
@@ -65,13 +65,14 @@ function buildShim() {
global.confirm = () => true;
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
function load(lang, psState) {
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'));
}
@@ -165,6 +166,9 @@ const EN = {
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/,
@@ -182,6 +186,9 @@ const FR = {
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. */
@@ -342,16 +349,78 @@ function runPsQuiz(lang, done) {
}, 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', () => {
ok('EN privacy line present in index.html', /never leave your browser/.test(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8')));
}, () => 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);
}))))))
}))))))))))
;