Retire Planner v1: engine (live 2026 CPP/OAS/GIS/RRIF/tax), 17-step UI, 4 SVG charts, vision gallery, tests
This commit is contained in:
commit
e25dd4e0a1
29 files changed
+3143
No files matched your search
@@ -0,0 +1,120 @@
|
||||
// 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);
|
||||
@@ -0,0 +1,109 @@
|
||||
// Headless smoke test for site/app.js — tiny DOM shim, drives the full flow.
|
||||
// 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 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); },
|
||||
addEventListener(t, fn) { this._l[t] = fn; },
|
||||
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));
|
||||
}
|
||||
global.document = {
|
||||
querySelector: q,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener() { },
|
||||
createElement: (t) => makeEl('el-' + t),
|
||||
};
|
||||
const store = {};
|
||||
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: {}, configurable: true });
|
||||
global.window = { scrollTo() { }, print() { } };
|
||||
global.confirm = () => true;
|
||||
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||
|
||||
// load engine + canada + app in order (classic scripts sharing globals)
|
||||
const engP = path.join(SITE, 'engine', 'engine.js'), canP = path.join(SITE, 'engine', 'canada.js'), appP = path.join(SITE, 'app.js');
|
||||
global.window.RetireEngine = require(engP);
|
||||
global.window.RetireCanada = require(canP);
|
||||
eval(fs.readFileSync(appP, 'utf8'));
|
||||
|
||||
// ---- drive the UI ----
|
||||
let fails = 0;
|
||||
function ok(name, cond, extra) {
|
||||
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(); };
|
||||
|
||||
ok('intro wiring: begin button has handler', !!q('#btn-begin')._l.click);
|
||||
click('btn-begin');
|
||||
ok('quiz screen shown, step 1 = province', /Where do you live/.test(q('#step-body')._html));
|
||||
|
||||
// province grid: app attaches onclick to opts via querySelectorAll (shim returns [])
|
||||
// → set province through state by faking a click on the first opt is impossible;
|
||||
// simulate by driving Next: province unset → Next disabled.
|
||||
ok('province step requires a pick (Next disabled)', q('#btn-next').disabled === true);
|
||||
// emulate the option click path: app.js assigns onclick to elements from querySelectorAll;
|
||||
// give it one opt for the current render:
|
||||
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', registry.__probe !== 'x' && !/Pick a province/.test(q('#step-body')._html));
|
||||
|
||||
// walk all steps
|
||||
let guard = 0;
|
||||
while (!q('#btn-next').disabled && guard++ < 40) {
|
||||
const h = q('#step-body')._html;
|
||||
if (/runs out|gap|vision/i.test(h) && /See my plan/.test(q('#btn-next').textContent)) break;
|
||||
click('btn-next');
|
||||
if (/Retirement Vision|vision/i.test(q('#scr-results')._html)) break;
|
||||
if (q('#scr-intro').hidden === false) break;
|
||||
// results render flips hidden
|
||||
if (q('#scr-results').hidden === false) break;
|
||||
}
|
||||
ok('reached results screen', q('#scr-results').hidden === false, 'steps=' + guard);
|
||||
ok('verdict rendered', /money|sustain|runs out/i.test(q('#verdict')._html));
|
||||
ok('gap cards rendered', /gcard/.test(q('#gap-cards')._html));
|
||||
ok('G1 balance chart is inline SVG', /<svg/.test(q('#g1')._html));
|
||||
ok('G2 income chart is inline SVG', /<svg/.test(q('#g2')._html));
|
||||
ok('G3 cash chart is inline SVG', /<svg/.test(q('#g3')._html));
|
||||
ok('year table has rows', (q('#yr-table')._html.match(/<tr/g) || []).length > 20);
|
||||
ok('stamp has 2026 + CPP max', /tax rates 2026/.test(q('#stamp').textContent) && /1,507\.65/.test(q('#stamp').textContent), q('#stamp').textContent.slice(0, 60));
|
||||
ok('vision card rendered', /Retirement Vision/.test(q('#vision-card')._html));
|
||||
ok('privacy line present in HTML', /never leave your browser/.test(fs.readFileSync(path.join(SITE, 'index.html'), 'utf8')));
|
||||
|
||||
// G4 async (setTimeout 30ms)
|
||||
setTimeout(() => {
|
||||
ok('G4 scenario bars rendered', /<svg/.test(q('#g4')._html), (q('#g4')._html || '').slice(0, 40));
|
||||
// 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 verdict (spending 95k)', /95/.test(q('#verdict')._html + q('#wi-spend-o').textContent), q('#wi-spend-o').textContent);
|
||||
ok('localStorage saved', !!store['retire-planner-v1']);
|
||||
console.log(fails ? '\nSMOKE-FAIL: ' + fails : '\nSMOKE-OK all ui smoke checks passed');
|
||||
process.exit(fails ? 1 : 0);
|
||||
}, 500);
|
||||
}, 250);
|
||||
Reference in new issue
Block a user