';
- var n = 0;
+ var n = 0, mixedAny = false;
proj.years.forEach(function (r) {
if (r.phase !== 'draw' && r.age % 5 !== 0) return; n++;
+ if (r.mixed) mixedAny = true; /* 2026-10 math fix: one partner retired, one still accumulating */
tbl += '
';
});
$('#yr-table').innerHTML = tbl; $('#yr-summary').innerHTML = t('r.yearlySummary', { n: n });
+ /* Legend for † mixed-phase rows (couples: growing Total + Withdraw > 0 rows explained). */
+ var mn = $('#yr-mixed-note');
+ if (mn) { mn.textContent = mixedAny ? t('t.mixedNote') : ''; mn.hidden = !mixedAny; }
var m2 = fmtMoney2;
$('#stamp').textContent = t('r.stamp', {
diff --git a/site/engine/engine.js b/site/engine/engine.js
index b105d24..6b62692 100644
--- a/site/engine/engine.js
+++ b/site/engine/engine.js
@@ -77,11 +77,18 @@
}
/* ---- OAS recovery tax (clawback) ---- */
+ // s.3(1) ETA: recovery applies ONLY to years OAS is actually payable/received —
+ // a pre-OAS-start pensioner has no OAS to recover. Callers guard with oasGross > 0
+ // (2026-10 math fix: pre-65 withdrawals must not generate a "clawback" on OAS never paid).
+ // Recovery is capped at the OAS received (Math.min at call sites) → net OAS is never negative.
function clawback(netIncome) {
return 0.15 * Math.max(0, (netIncome || 0) - V('oasClawbackThreshold'));
}
- /* ---- GIS (SIMPLIFIED — stamp: simplified) ---- */
+ /* ---- GIS (SIMPLIFIED — stamp: simplified; PAYABLE ONLY AT AGE 65+) ---- */
+ // Source fact: Service Canada — you must be 65 or older to receive the GIS.
+ // The 65+ floor is enforced at the call sites: solve() gates gis() on a >= 65,
+ // projectCouple() zeroes gisSum when the merged row age < 65 (2026-10 math fix).
// Only below ~$20k (single) / ~$26k (couple, both 65+) income ex-OAS.
// gis = max(0, gisMax − 0.5 × max(0, incomeExOAS − 5000)) (first $5k employment exemption approximated).
// Returns ANNUAL $, non-taxable.
@@ -174,6 +181,11 @@
var o = {}, k;
for (k in DEFAULTS) o[k] = DEFAULTS[k];
for (k in (inp || {})) if (inp[k] !== undefined && inp[k] !== null) o[k] = inp[k];
+ // FIRE-safe guard (2026-10 math fix): a stale saved retireAge below the current age
+ // (52-year-old returning user with retireAge 40, what-if slider below current age)
+ // would "retire" this run years in the past → clamp to next birthday. Age-30 user
+ // planning to retire at 40 is untouched.
+ if (+o.retireAge < +o.age + 1) o.retireAge = +o.age + 1;
return o;
}
@@ -233,8 +245,8 @@
var taxable1 = pre + W;
var opts1 = { pensionIncome: (a >= 65 && a >= psStart ? (p.pensionAnnual || 0) : 0) + (a >= 65 ? W : 0) };
var t1 = tax(taxable1, p.province, a, opts1);
- var c1 = clawback(taxable1);
- var g = gis(taxable1 - oasGross, 'single');
+ var c1 = oasGross > 0 ? Math.min(clawback(taxable1), oasGross) : 0; // clawback only in years OAS is received, capped at OAS paid
+ var g = a >= 65 ? gis(taxable1 - oasGross, 'single') : 0; // GIS payable only at 65+ (Service Canada)
var cash1 = pre + W + g - t1.total - c1;
var n = 0, t = 0, taxable = taxable1, tt = t1, cc = c1, opts = opts1;
var rem = need - cash1;
@@ -244,13 +256,13 @@
taxable = taxable1 + 0.5 * n;
opts = { pensionIncome: opts1.pensionIncome };
tt = tax(taxable, p.province, a, opts);
- cc = clawback(taxable);
- g = gis(taxable - oasGross, 'single');
+ cc = oasGross > 0 ? Math.min(clawback(taxable), oasGross) : 0; // same rule: no OAS received → no clawback; capped at OAS paid
+ g = a >= 65 ? gis(taxable - oasGross, 'single') : 0;
rem = need - (pre + W + g + n - tt.total - cc);
}
if (rem > 0 && tfsa > 0) {
t = Math.min(tfsa, rem);
- g = gis(taxable - oasGross, 'single');
+ g = a >= 65 ? gis(taxable - oasGross, 'single') : 0; // 65+ floor (TFSA draw is non-taxable; guard kept uniform)
rem = need - (pre + W + g + n + t - tt.total - cc);
}
var cash = pre + W + g + n + t - tt.total - cc;
@@ -348,25 +360,39 @@
if (Math.abs(shift) > 1) {
var tA = tax(ra.taxable - shift, p.province, yr, { pensionIncome: pa - shift });
var tB = tax(rb.taxable + shift, p.province, yr, { pensionIncome: pb2 + shift });
- var newTax = tA.total + tB.total, newClaw = clawback(ra.taxable - shift) + clawback(rb.taxable + shift);
+ var newTax = tA.total + tB.total;
+ // Splitting comparison uses the SAME clawback rule: only a partner receiving OAS
+ // this year can generate recovery tax (2026-10 math fix).
+ var newClaw = (ra.oasGross > 0 ? Math.min(clawback(ra.taxable - shift), ra.oasGross) : 0)
+ + (rb.oasGross > 0 ? Math.min(clawback(rb.taxable + shift), rb.oasGross) : 0);
if (newTax + newClaw < taxSum + clawSum) { taxSum = newTax; clawSum = newClaw; }
}
}
row.tax = taxSum; row.oasClawback = clawSum;
- // GIS: household rate when both 65+, else sum of singles
+ // GIS: household rate when both 65+, else sum of singles. GIS is payable ONLY at
+ // 65+ (Service Canada): a partner's single GIS term counts only at their own age ≥ 65.
var incA = ra.taxable - ra.oasGross, incB = rb.taxable - rb.oasGross;
var gisSum;
if (ra.phase === 'draw' && rb.phase === 'draw' && ra.age >= 65 && rb.age >= 65) {
gisSum = gis(incA + incB, 'couple');
} else {
- gisSum = (ra.phase === 'draw' ? gis(incA, 'single') : 0) + (rb.phase === 'draw' ? gis(incB, 'single') : 0);
+ gisSum = (ra.phase === 'draw' && ra.age >= 65 ? gis(incA, 'single') : 0)
+ + (rb.phase === 'draw' && rb.age >= 65 ? gis(incB, 'single') : 0);
}
+ if (yr < 65) gisSum = 0; // GIS requires age 65+ (Service Canada) — 2026-10 math fix
row.gis = gisSum;
var cash = ra.cash + rb.cash + (ra.gis + rb.gis ? gisSum - ra.gis - rb.gis : 0) + (taxSum ? (ra.tax + rb.tax - taxSum) : 0);
cash = ra.cash + rb.cash - (ra.gis + rb.gis) + gisSum + (ra.tax + rb.tax) - taxSum;
row.cash = Math.min(cash, row.need);
+ // Mixed window: retirement HAS begun (row labelled 'draw' from the FIRST retirement),
+ // but one partner is still accumulating at the pre-retirement rate. Honest summation,
+ // and the renderer footnotes these rows (†) so a growing Total + Withdraw > 0 is explained.
+ row.mixed = row.phase === 'draw' && (ra.phase === 'accum' || rb.phase === 'accum');
row.unfunded = Math.max(0, row.need - cash);
- if (row.phase === 'draw' && row.totalBal <= 0.5 && row.unfunded > 0.5 && depletionAge === null) depletionAge = yr;
+ // Depletion must not fire in a mixed window: the balance there is mostly the
+ // accum partner's pre-retirement money, not the drawing partner's. Count fully-drawing
+ // rows only — a missing partner row (phase 'none') counts as sole income (not accum).
+ if (row.phase === 'draw' && !row.mixed && row.totalBal <= 0.5 && row.unfunded > 0.5 && depletionAge === null) depletionAge = yr;
totalTax += taxSum; totalClawback += clawSum;
merged.push(row);
}
@@ -445,6 +471,7 @@
recommendations: recommendations,
pensionStartAge: pensionStartAge,
pensionBridgeAnnual: pensionBridgeAnnual,
+ fillDefaults: fillDefaults,
defaults: DEFAULTS,
constants: C
};
diff --git a/site/i18n.js b/site/i18n.js
index 79a2271..c3a028b 100644
--- a/site/i18n.js
+++ b/site/i18n.js
@@ -93,7 +93,7 @@
title: 'Monthly savings into RRSP', hint: 'Your contribution + any employer match.',
you: 'You contribute $/mo', match: 'Employer match $/mo'
},
- retire: { title: 'Retirement age', hint: 'When you stop earning employment income.', years: '{v} years' },
+ retire: { title: 'Retirement age', hint: 'When you stop earning employment income. Early retirement (FIRE) supported — plan must self-fund the CPP/OAS gap years.', years: '{v} years' },
spend: { title: 'Planned annual spending in retirement', hint: 'Today’s dollars, after tax. Be honest — this is the number that decides everything.' },
cppstart: {
title: 'Start CPP at…', hint: 'Age {age}: {adj}',
@@ -247,7 +247,8 @@
},
t: {
age: 'Age', rrsp: 'RRSP', tfsa: 'TFSA', nonreg: 'Non-reg', total: 'Total', wdr: 'Withdraw',
- cpp: 'CPP', oas: 'OAS', gis: 'GIS', tax: 'Tax+claw', unfunded: 'Unfunded'
+ cpp: 'CPP', oas: 'OAS', gis: 'GIS', tax: 'Tax+claw', unfunded: 'Unfunded',
+ mixedNote: '† One partner is retired, the other is still working: their savings grow at the pre-retirement rate — this row is not in full retirement mode.'
},
d: {
main: 'Estimates only — not financial advice. Simplified model: GIS uses a simplified 50% phase-out; Quebec shown with the 16.5% federal abatement (QPP and Quebec payroll taxes not modelled); federal public service pension (PSSA) modelled when enabled — other DB pensions not; returns are constant real rates; Ontario surtax modelled on base tax.',
@@ -353,7 +354,7 @@
title: 'Épargne mensuelle au REER', hint: 'Votre cotisation + la contrepartie de l\'employeur.',
you: 'Vous cotisez', match: 'Contrepartie de l\'employeur'
},
- retire: { title: 'Âge de la retraite', hint: 'Quand vous cessez de gagner un revenu d\'emploi.', years: '{v} ans' },
+ retire: { title: 'Âge de la retraite', hint: 'Quand vous cessez de gagner un revenu d\'emploi. La retraite anticipée (FIRE) est prise en charge — le plan doit autofinancer les années sans RPC/SV.', years: '{v} ans' },
spend: { title: 'Dépenses annuelles prévues à la retraite', hint: 'En dollars d\'aujourd\'hui, après impôt. Soyez honnête — c\'est le chiffre qui décide de tout.' },
cppstart: {
title: 'Débuter la RPC à…', hint: 'À {age} ans : {adj}',
@@ -507,7 +508,8 @@
},
t: {
age: 'Âge', rrsp: 'REER', tfsa: 'CELI', nonreg: 'Non enreg.', total: 'Total', wdr: 'Retrait',
- cpp: 'RPC', oas: 'SV', gis: 'SRG', tax: 'Impôt + récup.', unfunded: 'Non financé'
+ cpp: 'RPC', oas: 'SV', gis: 'SRG', tax: 'Impôt + récup.', unfunded: 'Non financé',
+ mixedNote: '† Un partenaire est à la retraite, l\'autre travaille encore : son épargne croît au taux pré-retraite — cette ligne n\'est pas en mode retraite complet.'
},
d: {
main: 'Estimations seulement — il ne s\'agit pas de conseils financiers. Modèle simplifié : retrait progressif simplifié de 50 % pour la SRG; Québec présenté avec le dégagement fédéral de 16,5 % (RRQ et cotisations sociales québécoises non modélisés); pension de la fonction publique fédérale (LRFP) modélisée si activée — autres pensions à prestations non modélisées; rendements à taux réels constants; surtaxe de l\'Ontario calculée sur l\'impôt de base.',
diff --git a/site/index.html b/site/index.html
index c5f6fe0..bb5e0bc 100644
--- a/site/index.html
+++ b/site/index.html
@@ -83,7 +83,7 @@
+
@@ -92,6 +92,7 @@
Year-by-year detail (0 years)
+
diff --git a/tests/engine.test.js b/tests/engine.test.js
index 3a299fb..5ed9cf9 100644
--- a/tests/engine.test.js
+++ b/tests/engine.test.js
@@ -116,5 +116,91 @@ const onQc = E.tax(80000, "QC", 40, {});
ok("QC federal = 83.5% of ON federal", near(onQc.federal, E.tax(80000, "ON", 40, {}).federal * 0.835, 1));
ok("QC total > ON total at 80k", onQc.total > E.tax(80000, "ON", 40, {}).total, onQc.total.toFixed(0));
+console.log("---- 14. Math fix (2026-10): GIS 65+ floor, clawback only with OAS received, couple mixed rows, FIRE ----");
+// T1 — PASS-1 repro (plan evidence): 52y ON, 60k, 100k/30k/20k, retire 55, spend 93k, CPP 60.
+// Old engine: age 55 OASnet −$3,501 (clawback on OAS never paid), age 56+ GIS $12,916 (GIS is 65+ ONLY).
+const t1 = E.project(inp({ age: 52, province: 'ON', income: 60000, rrsp: 100000, tfsa: 30000, nonreg: 20000,
+ monthlyContribution: 0, employerMatchMonthly: 0, retireAge: 55, spendingAnnual: 93000, cppStartAge: 60, oasStartAge: 65 }));
+const t1y55 = t1.years.filter(y => y.age === 55)[0], t1y56 = t1.years.filter(y => y.age === 56)[0];
+ok("T1 clawback == 0 at 55 (pre-OAS; old: −$3,501 net OAS)", t1y55.oasClawback === 0, t1y55.oasClawback);
+ok("T1 GIS == 0 at 56 (old: $12,916 phantom)", t1y56.gis === 0, t1y56.gis);
+ok("T1 OAS gross == 0 at 56", t1y56.oasGross === 0);
+ok("T1 net OAS ≥ 0 in every row", t1.years.every(y => (y.oasGross - y.oasClawback) >= 0));
+ok("T1 no GIS below 65 anywhere", t1.years.filter(y => y.age < 65 && y.gis > 0).length === 0);
+
+console.log("---- 15. Clawback active when OAS IS received ----");
+const t2a = E.project(inp({ age: 52, province: 'ON', income: 60000, rrsp: 300000, tfsa: 30000, nonreg: 20000,
+ monthlyContribution: 0, retireAge: 55, spendingAnnual: 60000, cppStartAge: 60, oasStartAge: 65,
+ rrspWithdrawalOverrideAnnual: 100000 }));
+const t2a55 = t2a.years.filter(y => y.age === 55)[0];
+ok("100k RRSP draw at 55 (taxable > clawback threshold) → clawback 0",
+ t2a55.rrifWithdrawal > 90000 && t2a55.oasClawback === 0,
+ "W=" + Math.round(t2a55.rrifWithdrawal) + " cw=" + t2a55.oasClawback + " taxable=" + Math.round(t2a55.taxable));
+const t2b = E.project(inp({ age: 66, province: 'ON', income: 60000, rrsp: 500000, tfsa: 30000, nonreg: 20000,
+ monthlyContribution: 0, retireAge: 67, spendingAnnual: 100000, cppStartAge: 65, oasStartAge: 65,
+ rrspWithdrawalOverrideAnnual: 150000 }));
+const t2b67 = t2b.years.filter(y => y.age === 67)[0];
+ok("67y OAS recipient, 150k draw → clawback > 0", t2b67.oasGross > 0 && t2b67.oasClawback > 0,
+ "oas=" + Math.round(t2b67.oasGross) + " cw=" + Math.round(t2b67.oasClawback) + " taxable=" + Math.round(t2b67.taxable));
+ok("clawback capped at OAS gross", t2b67.oasClawback <= t2b67.oasGross + 1);
+
+console.log("---- 16. Couple merge: GIS 65+ floor, age-offset rows, depletion guard ----");
+// (a) screenshot scenario: symmetric couple, ret 55 — pre-65 draw rows must have GIS 0 / clawback 0
+const cpSym = E.project(inp({ household: 'couple', age: 52, province: 'ON', income: 60000, rrsp: 100000, tfsa: 30000,
+ nonreg: 20000, monthlyContribution: 0, spendingAnnual: 93000, retireAge: 55, cppStartAge: 60,
+ partner: { age: 52, income: 60000, rrsp: 100000, tfsa: 30000, nonreg: 20000, monthlyContribution: 0 } }));
+ok("couple: zero GIS below 65 (old: $13k/yr phantom rows 57–62)", cpSym.years.filter(y => y.age < 65 && y.gis > 0).length === 0);
+ok("couple: zero clawback below 65", cpSym.years.filter(y => y.age < 65 && y.oasClawback > 0).length === 0);
+ok("couple: net OAS ≥ 0 everywhere", cpSym.years.every(y => (y.oasGross - y.oasClawback) >= 0));
+ok("couple: no growing-Total draw rows (Withdraw>0 & Total↑)", (function () {
+ const a = cpSym.years;
+ for (let i = 1; i < a.length; i++) {
+ const W = (a[i].rrifWithdrawal || 0) + (a[i].nonregW || 0) + (a[i].tfsaW || 0);
+ if (a[i].phase === 'draw' && W > 1000 && a[i].totalBal > a[i - 1].totalBal) return false;
+ } return true;
+})());
+ok("couple: GIS paid at 65+ (household rate)", cpSym.years.filter(y => y.age === 70)[0].gis > 0,
+ cpSym.years.filter(y => y.age === 70)[0].gis.toFixed(0));
+// (b) plan test 3: partner 3 years younger (pt.age 50, p.age 52, retireAge 55).
+// The couple merge aligns partners on the AGE axis (byYear keys y.age) and both partners
+// share retireAge, so a same-retireAge couple has NO mixed draw window: rows 50–51 are the
+// younger partner's accumulation-only rows (older partner has no row yet → phase 'none').
+// Plan-literal "rows 50–54 mixed===true" is unreachable in this merge design — asserted as
+// the honest equivalent (window exists, totals grow — fine, phase accum, no false depletion).
+const cpOff = E.project(inp({ household: 'couple', age: 52, province: 'ON', income: 60000, rrsp: 100000, tfsa: 30000,
+ nonreg: 20000, monthlyContribution: 500, spendingAnnual: 93000, retireAge: 55,
+ partner: { age: 50, income: 0, rrsp: 20000, tfsa: 10000, nonreg: 5000, monthlyContribution: 200 } }));
+const w5054 = [50, 51, 52, 53, 54].map(a => cpOff.years.filter(y => y.age === a)[0]);
+ok("age-offset couple: partner-priority window rows 50–54 present", w5054.every(Boolean));
+ok("window totals GROW (accum rows — 'growing totals — fine')", (function () {
+ let prev = -1; return w5054.every(y => { const g = y.totalBal > prev; prev = y.totalBal; return g; });
+})());
+ok("window rows phase 'accum' (engine aligns by age; documented in report)", w5054.every(y => y.phase === 'accum'));
+ok("no depletion in the partner-priority window", w5054.every(y => y.phase !== 'draw'));
+ok("mixed flag never true on non-draw rows", cpOff.years.filter(y => y.phase !== 'draw' && y.mixed).length === 0);
+// (c) depletion guard: real depletion in fully-draw rows still detected (guard not broken)
+const cpDep = E.project(inp({ household: 'couple', age: 66, province: 'ON', income: 0, rrsp: 20000, tfsa: 5000,
+ nonreg: 1000, monthlyContribution: 0, spendingAnnual: 120000, retireAge: 66, cppStartAge: 66, oasStartAge: 65,
+ partner: { age: 66, income: 0, rrsp: 5000, tfsa: 1000, nonreg: 0, monthlyContribution: 0 } }));
+ok("fully-draw couple running out: depletionAge non-null (guard intact)", cpDep.depletionAge != null, cpDep.depletionAge);
+ok("depletion never fires on a mixed row", (function () {
+ const d = cpOff.depletionAge;
+ return d === null || !cpOff.years.filter(y => y.age === d)[0].mixed;
+})());
+
+console.log("---- 17. FIRE: retire age 40 + stale-save clamp ----");
+ok("fillDefaults({age:52, retireAge:40}) → 53 (stale saved state guard)",
+ E.fillDefaults({ age: 52, retireAge: 40 }).retireAge === 53, E.fillDefaults({ age: 52, retireAge: 40 }).retireAge);
+ok("fillDefaults({age:30, retireAge:40}) → 40 (FIRE plan untouched)",
+ E.fillDefaults({ age: 30, retireAge: 40 }).retireAge === 40, E.fillDefaults({ age: 30, retireAge: 40 }).retireAge);
+const fire = E.project(inp({ age: 30, province: 'BC', income: 90000, rrsp: 200000, tfsa: 100000, nonreg: 50000,
+ monthlyContribution: 0, retireAge: 40, spendingAnnual: 60000, cppStartAge: 60 }));
+ok("FIRE project: first draw row at age 40", fire.years.filter(y => y.phase === 'draw')[0].age === 40,
+ fire.years.filter(y => y.phase === 'draw')[0].age);
+ok("FIRE: rows 40–64 have GIS 0 and clawback 0 (self-funded gap years)",
+ fire.years.filter(y => y.age < 65 && (y.gis > 0 || y.oasClawback > 0)).length === 0);
+ok("stale 52y/retire-40 state: project clamps → first draw at 53",
+ E.project(inp({ age: 52, retireAge: 40 })).years.filter(y => y.phase === 'draw')[0].age === 53);
+
console.log("\nRESULT: " + pass + " pass, " + fail + " fail");
process.exit(fail ? 1 : 0);
diff --git a/tests/i18n.test.js b/tests/i18n.test.js
index d0a71e8..57e9010 100644
--- a/tests/i18n.test.js
+++ b/tests/i18n.test.js
@@ -171,5 +171,14 @@ 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());
+/* ---------- 2026-10 math fix: mixed-row legend + FIRE hint keys ---------- */
+ok('t.mixedNote present EN + FR (mixed-row legend)',
+ typeof get(I18N.en, 't.mixedNote') === 'string' && get(I18N.en, 't.mixedNote').indexOf('†') === 0 &&
+ typeof get(I18N.fr, 't.mixedNote') === 'string' && get(I18N.fr, 't.mixedNote').indexOf('†') === 0,
+ get(I18N.en, 't.mixedNote').slice(0, 40));
+ok('q.retire.hint mentions FIRE support EN + FR',
+ /FIRE/.test(get(I18N.en, 'q.retire.hint')) && /FIRE/.test(get(I18N.fr, 'q.retire.hint')),
+ get(I18N.fr, 'q.retire.hint').slice(0, 60));
+
console.log(fails ? '\nI18N-FAIL: ' + fails + ' of ' + total : '\nI18N-OK all i18n checks passed (' + total + ')');
process.exit(fails ? 1 : 0);
diff --git a/tests/ui.smoke.js b/tests/ui.smoke.js
index 8f96ecd..b2a07a5 100644
--- a/tests/ui.smoke.js
+++ b/tests/ui.smoke.js
@@ -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(/]*>/) || [''])[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 cell', /[^<]*\s†<\/b>/.test(tblHtml), (tblHtml.match(/[^<]*†[^<]*<\/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);
-}))))))
+}))))))))))
;