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

121 lines
7.4 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("\nRESULT: " + pass + " pass, " + fail + " fail");
process.exit(fail ? 1 : 0);