Files
retire-planner/tests/engine.test.js
T

207 lines
14 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.
// Retire-planner engine tests — run: node tests/engine.test.js (PLAN.md Stage 2 gate)
'use strict';
const E = require("../site/engine/engine.js");
const C = require("../site/engine/canada.js");
let pass = 0, fail = 0;
function ok(name, cond, detail) {
if (cond) { pass++; console.log(" PASS " + name + (detail !== undefined ? " [" + detail + "]" : "")); }
else { fail++; console.log(" FAIL " + name + (detail !== undefined ? " [" + detail + "]" : "")); }
}
function near(a, b, tol) { return Math.abs(a - b) <= tol; }
const inp = function (o) { return Object.assign({}, E.defaults, o); };
console.log("Retire-planner engine tests (constants asOf: " + C.asOf + ")");
console.log("---- 1. CPP early/late adjustments (plan: 60→0.64×, 70→1.42×) ----");
const at65 = E.estimateCppMonthlyAt65(60000, 40);
ok("cppMonthlyAt(65,60,at65) == 0.64×at65", near(E.cppMonthlyAt(65, 60, at65), 0.64 * at65, 1e-9),
E.cppMonthlyAt(65, 60, at65).toFixed(2));
// Plan literal is cppMonthlyAt(65,70,…)==1.42×at65 — self-contradictory (pension not
// payable at 65 if starting 70 → engine correctly returns 0). Asserted at payable age 70;
// literal (65,70)==0 also asserted. Deviation noted in REPORT.md.
ok("cppMonthlyAt(70,70,at65) == 1.42×at65", near(E.cppMonthlyAt(70, 70, at65), 1.42 * at65, 1e-9),
E.cppMonthlyAt(70, 70, at65).toFixed(2));
ok("cppMonthlyAt(65,70,at65) == 0 (not yet started — plan literal quirk)", E.cppMonthlyAt(65, 70, at65) === 0);
ok("estimateCppMonthlyAt65(500k,45) ≤ cppMax " + C.cppMax65Monthly.value,
E.estimateCppMonthlyAt65(500000, 45) <= C.cppMax65Monthly.value + 1e-9,
E.estimateCppMonthlyAt65(500000, 45).toFixed(2));
console.log("---- 2. RRIF minimums (plan: 71→5.28, 95+→20) ----");
ok("rrifMinPct(71)==5.28", E.rrifMinPct(71) === 5.28, E.rrifMinPct(71));
ok("rrifMinPct(95)==20.0", E.rrifMinPct(95) === 20.0);
ok("rrifMinPct(70)==0", E.rrifMinPct(70) === 0);
console.log("---- 3. ON tax on 80k taxable @40 (plan band 13,000–15,500 on 2025 brackets) ----");
const t80 = E.tax(80000, "ON", 40, {}).total;
console.log(" actual: " + t80.toFixed(2) + " (live 2026 rates: fed 14% first bracket, ON BPA 12,989)");
ok("t80 in 12,500–15,500", t80 > 12500 && t80 < 15500, t80.toFixed(0));
console.log("---- 4. GIS at 10k income ex-OAS, single ----");
// Plan band 8,200–9,500 came from 2025 max (1,086.88/mo) with a $2,500 offset. Live 2026
// GIS (1,138.90/mo) and the official $5,000 employment exemption →
// 13,666.80 − 0.5×(10,000−5,000) = 11,166.80. Band re-derived from live constants.
const g = E.gis(10000, "single");
console.log(" actual gis(10000,single): " + g.toFixed(2));
ok("gis in 9,700–12,000 (2026-constant band)", g > 9700 && g < 12000, g.toFixed(0));
ok("gis(24000,single)==0 (≥ 2026 threshold 23,112)", E.gis(24000, "single") === 0);
ok("gis(0,single)==gisMax×12", E.gis(0, "single") === C.gisMaxSingleMonthly.value * 12);
console.log("---- 5. OAS deferral + 75 boost ----");
ok("oas(66,65)==max " + C.oasMax6574Monthly.value, E.oasMonthlyAt(66, 65) === C.oasMax6574Monthly.value);
ok("oas(64,65)==0", E.oasMonthlyAt(64, 65) === 0);
ok("deferral capped +36% at 70", near(E.oasMonthlyAt(70, 70), C.oasMax6574Monthly.value * 1.36, 1e-9));
ok("75 boost ×1.10", near(E.oasMonthlyAt(75, 65), C.oasMax6574Monthly.value * 1.10, 1e-9),
E.oasMonthlyAt(75, 65).toFixed(2));
console.log("---- 6. Depletion scenario (plan): 45y, 50k inc, 50k RRSP, 30k TFSA, retire 65, spend 100k ----");
const depInp = inp({ age: 45, income: 50000, rrsp: 50000, tfsa: 30000, nonreg: 20000,
retireAge: 65, spendingAnnual: 100000, monthlyContribution: 0, employerMatchMonthly: 0 });
const dep = E.project(depInp);
console.log(" depletionAge: " + dep.depletionAge);
ok("depletionAge non-null", dep.depletionAge != null, dep.depletionAge);
console.log("---- 7. sustainableSpending on depleted case ----");
const ss = E.sustainableSpending(depInp);
console.log(" sustainableSpending: " + ss.annual + "/yr (" + Math.round(ss.monthly) + "/mo)");
ok("ss ≤ 100,000 spending input", ss.annual <= 100000);
ok("ss > 0", ss.annual > 0);
ok("ss scenario: spending at ss survives (depletionAge null)",
E.project(Object.assign({}, depInp, { spendingAnnual: ss.annual })).depletionAge === null);
console.log("---- 8. Defaults run realistically; a funded scenario survives ----");
const def = E.project(E.defaults);
console.log(" defaults (100k RRSP @45, spend 50k): depletionAge=" + def.depletionAge +
" (typical Canadian → depletes in early 80s; drives gap cards)");
ok("defaults: last row age ≥ 94", def.years[def.years.length - 1].age >= 94, def.years[def.years.length - 1].age);
ok("defaults: cash@70 == spending (funded until depletion)", near(def.years.filter(r => r.age === 70)[0].cash, 50000, 1));
const rich = E.project(inp({ rrsp: 400000, tfsa: 100000, nonreg: 80000 }));
ok("funded scenario: depletionAge null", rich.depletionAge === null, "depletionAge=" + rich.depletionAge);
ok("funded scenario: final balance ≥ 0", rich.finalBal >= 0, Math.round(rich.finalBal));
console.log("---- 9. RRIF minimum enforced at 71+ ----");
const rr = E.project(inp({ rrsp: 800000, tfsa: 50000, nonreg: 50000, spendingAnnual: 40000 }));
const bad = rr.years.filter(r => r.phase === 'draw' && r.age >= 71 && r.rrspBal > 1000 &&
r.rrifWithdrawal < 0.95 * (E.rrifMinPct(r.age) / 100) * (r.rrspBal + r.rrifWithdrawal));
ok("all draw rows 71+ withdraw ≥ 95% of RRIF min on Jan balance", bad.length === 0, bad.length + " violations");
console.log("---- 10. Couple: merge, splitting, GIS ----");
const cpInp = inp({ household: 'couple', spendingAnnual: 70000, rrsp: 150000, tfsa: 40000, nonreg: 30000,
partner: { age: 45, income: 30000, rrsp: 20000, tfsa: 20000, nonreg: 5000, monthlyContribution: 200 } });
const cp = E.project(cpInp);
ok("couple: rows to 94", cp.years[cp.years.length - 1].age >= 94);
ok("couple: need = 70,000 at 65+", cp.years.filter(r => r.age === 70)[0].need === 70000);
ok("couple modest: GIS paid at 70", cp.years.filter(r => r.age === 70)[0].gis > 0,
cp.years.filter(r => r.age === 70)[0].gis.toFixed(0));
ok("couple: no depletion", cp.depletionAge === null);
console.log("---- 11. recommendations (3 gap closers) on depleted case ----");
const rec = E.recommendations(depInp, dep);
console.log(" gap=" + rec.gapAnnual + " extraMonthly=" + rec.extraMonthly +
" retireDelayYears=" + rec.retireDelayYears + " spendingReduction=" + rec.spendingReduction);
ok("gapAnnual > 0 on depleted", rec.gapAnnual > 0);
ok("spendingReduction > 0", rec.spendingReduction > 0);
ok("extraMonthly ≥ 0", rec.extraMonthly >= 0);
ok("retireDelayYears 0..10", rec.retireDelayYears >= 0 && rec.retireDelayYears <= 10);
console.log("---- 12. marginalRate (ON, 40, 90k): fed 20.5% + ON 9.15% ≈ 29.7% (2026 fed 14% bottom) ----");
const m = E.marginalRate(90000, "ON", 40, {});
ok("marginal 27–34%", m > 0.27 && m < 0.34, (m * 100).toFixed(1) + "%");
console.log("---- 13. Tax monotonic + QC abatement ----");
ok("tax monotonic 0..200k", (function () {
let prev = -1; for (let x = 0; x <= 200000; x += 1000) { const t = E.tax(x, "ON", 40, {}).total; if (t < prev - 1e-6) return false; prev = t; } return true;
})());
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);