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

+2
View File
@@ -6,3 +6,5 @@ site/vision/*.jpeg
.tmp/ .tmp/
audit/ps-pension/*.png audit/ps-pension/*.png
audit/lang-right/**/*.png audit/lang-right/**/*.png
audit/math-fix/
.pwtmp/
+2 -2
View File
@@ -4,8 +4,8 @@
"has_lang_toggle": true, "has_lang_toggle": true,
"has_i18n_script": true, "has_i18n_script": true,
"i18n_js_fr_strings": true, "i18n_js_fr_strings": true,
"i18n_js_size": 28788, "i18n_js_size": 39689,
"checked_at": "2026-10-09T14:01:51Z", "checked_at": "2026-10-09T19:52:48Z",
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)", "vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
"all_ok": true "all_ok": true
} }
+124
View File
@@ -0,0 +1,124 @@
"""2026-10 math-fix evidence capture (GIS 65 floor / clawback-only-with-OAS / mixed † / FIRE 40).
Usage: python3 audit/mathfix_capture.py TAG BASE (TAG = before|after)
Screenshots + row JSON under audit/math-fix/. Live couple scenario 52/52 ON 60k
100k/30k/20k ret 55 spend 93k CPP 60 (what-if ret 55), FIRE 30/40 BC.
"""
import json, os, re, sys
from playwright.sync_api import sync_playwright
TAG = sys.argv[1] if len(sys.argv) > 1 else 'after'
BASE = (sys.argv[2] if len(sys.argv) > 2 else 'https://retire.krisforbes.ca/').rstrip('/') + '/'
OUT = os.path.join(os.path.dirname(__file__), 'math-fix')
os.makedirs(OUT, exist_ok=True)
CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'
LS = 'retire-planner-v1'
COUPLE = {
"province": "ON", "age": 52, "household": "couple",
"income": 60000, "rrsp": 100000, "tfsa": 30000, "nonreg": 20000,
"monthlyContribution": 0, "employerMatchMonthly": 0,
"retireAge": 55, "spendingAnnual": 93000, "cppStartAge": 60, "oasStartAge": 65,
"realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95,
"partner": {"age": 52, "income": 60000, "rrsp": 100000, "tfsa": 30000, "nonreg": 20000,
"monthlyContribution": 0},
}
FIRE = {
"province": "BC", "age": 30, "household": "single",
"partner": {"age": 30, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0},
"income": 90000, "rrsp": 200000, "tfsa": 100000, "nonreg": 50000,
"monthlyContribution": 0, "employerMatchMonthly": 0,
"retireAge": 40, "spendingAnnual": 60000, "cppStartAge": 60, "oasStartAge": 65,
"realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95,
}
ROWS_JS = """() => [...document.querySelectorAll('#yr-table tr')].map(tr =>
[...tr.children].map(td => td.textContent.trim()))"""
def open_table(pg):
pg.evaluate("() => { const d = document.querySelector('details.yearly'); if (d) d.open = true; }")
pg.wait_for_timeout(250)
def capture(browser, state, name, lang='en'):
ctx = browser.new_context(viewport={'width': 1280, 'height': 900})
pg = ctx.new_page()
pg.on('dialog', lambda d: d.accept())
pg.goto(BASE, wait_until='domcontentloaded')
pg.evaluate("([ls, st, lang]) => localStorage.setItem(ls, JSON.stringify({a: st, step: 0, screen: 'results', lang}))",
(LS, state, lang))
pg.reload(wait_until='networkidle')
pg.wait_for_timeout(900) # renderResults + charts (G4 fires at 30ms)
open_table(pg)
pg.evaluate("() => document.querySelector('#yr-table').scrollIntoView({block: 'start'})")
pg.wait_for_timeout(200)
rows = pg.evaluate(ROWS_JS)
pg.screenshot(path=os.path.join(OUT, '%s-%s-table.png' % (TAG, name)))
geo = pg.evaluate("""() => {
const note = document.querySelector('#yr-mixed-note');
const wi = document.querySelector('#wi-ret');
return {noteShown: note ? !note.hidden && note.textContent.trim().length > 0 : null,
noteText: note ? note.textContent.trim().slice(0, 120) : null,
wiRetMin: wi ? wi.getAttribute('min') : null,
depletionBanner: /runs out|runs out|deplet/i.test(document.querySelector('#verdict')?.textContent || '')};
}""")
ctx.close()
return rows, geo
def capture_fire_step_390(browser):
"""Mobile 390: quiz retire step — slider min=40, no label clipping."""
ctx = browser.new_context(viewport={'width': 390, 'height': 844}, device_scale_factor=3,
user_agent='Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1')
pg = ctx.new_page()
pg.goto(BASE, wait_until='domcontentloaded')
pg.evaluate("(ls) => localStorage.setItem(ls, JSON.stringify({a:{province:'ON', age:30, household:'single', income:90000, rrsp:200000, tfsa:100000, nonreg:50000, monthlyContribution:0, retireAge:40, spendingAnnual:60000, cppStartAge:60}, step: 0, screen: 'quiz', lang: 'en'}))", LS)
pg.reload(wait_until='networkidle')
# screen:'quiz' drops straight on the province step (intro only shows on fresh state)
pg.wait_for_timeout(400)
pg.evaluate("() => { const o = document.querySelector('#step-body .opt'); if (o) o.click(); }")
pg.wait_for_timeout(150)
for _ in range(10):
h = pg.evaluate("() => document.querySelector('#step-body').textContent")
if 'Retirement age' in h:
break
dis = pg.evaluate("() => document.querySelector('#btn-next').disabled")
if dis:
break
pg.click('#btn-next')
pg.wait_for_timeout(120)
info = pg.evaluate("""() => {
const s = document.querySelector('#f1');
const sv = document.querySelector('#sv');
const r = s.getBoundingClientRect(), rv = sv.getBoundingClientRect();
return {min: s.getAttribute('min'), max: s.getAttribute('max'), value: s.value,
sliderRight: +r.right.toFixed(1), vw: innerWidth,
valRight: +rv.right.toFixed(1), valLeft: +rv.left.toFixed(1),
hint: (document.querySelector('#step-body .hint') || {}).textContent || ''};
}""")
# drag to 40 via input event (range fill)
pg.evaluate("() => { const s = document.querySelector('#f1'); s.value = '40'; s.dispatchEvent(new Event('input', {bubbles: true})); }")
pg.wait_for_timeout(150)
info['svAfter40'] = pg.evaluate("() => document.querySelector('#sv').textContent")
pg.screenshot(path=os.path.join(OUT, '%s-fire-step-390.png' % TAG))
ctx.close()
return info
with sync_playwright() as p:
browser = p.chromium.launch(executable_path=CHROME, args=['--no-sandbox'])
rows, geo = capture(browser, COUPLE, 'couple')
with open(os.path.join(OUT, '%s-couple-rows.json' % TAG), 'w') as f:
json.dump({'rows': rows, 'geo': geo}, f, indent=1)
out = {'geo': geo}
if TAG == 'after':
frows, fgeo = capture(browser, FIRE, 'fire')
with open(os.path.join(OUT, '%s-fire-rows.json' % TAG), 'w') as f:
json.dump({'rows': frows, 'geo': fgeo}, f, indent=1)
out['fire_first_draw_age'] = next((r[0] for r in frows[1:] if len(r) == 11 and float(re.sub(r'[^0-9.]', '', r[5]) or 0) > 0), None)
out['fire_geo'] = fgeo
out['fire_step_390'] = capture_fire_step_390(browser)
browser.close()
print('%s capture done -> %s' % (TAG, OUT))
print(json.dumps(out, indent=1)[:1200])
+7 -3
View File
@@ -208,7 +208,7 @@
}, },
{ {
key: 'retire', titleK: 'q.retire.title', hintK: 'q.retire.hint', type: 'slider', key: 'retire', titleK: 'q.retire.title', hintK: 'q.retire.hint', type: 'slider',
min: 55, max: 72, step: 1, fmt: function (v) { return t('q.retire.years', { v: v }); }, min: 40, max: 72, step: 1, fmt: function (v) { return t('q.retire.years', { v: v }); }, /* FIRE: floor 40 (fillDefaults clamps below current age) */
get: function () { return state.a.retireAge; }, set: function (v) { state.a.retireAge = +v; } get: function () { return state.a.retireAge; }, set: function (v) { state.a.retireAge = +v; }
}, },
{ {
@@ -804,15 +804,19 @@
renderPsCard(p); renderPsCard(p);
var tbl = '<tr><th>' + t('t.age') + '</th><th>' + t('t.rrsp') + '</th><th>' + t('t.tfsa') + '</th><th>' + t('t.nonreg') + '</th><th>' + t('t.total') + '</th><th>' + t('t.wdr') + '</th><th>' + t('t.cpp') + '</th><th>' + t('t.oas') + '</th><th>' + t('t.gis') + '</th><th>' + t('t.tax') + '</th><th>' + t('t.unfunded') + '</th></tr>'; var tbl = '<tr><th>' + t('t.age') + '</th><th>' + t('t.rrsp') + '</th><th>' + t('t.tfsa') + '</th><th>' + t('t.nonreg') + '</th><th>' + t('t.total') + '</th><th>' + t('t.wdr') + '</th><th>' + t('t.cpp') + '</th><th>' + t('t.oas') + '</th><th>' + t('t.gis') + '</th><th>' + t('t.tax') + '</th><th>' + t('t.unfunded') + '</th></tr>';
var n = 0; var n = 0, mixedAny = false;
proj.years.forEach(function (r) { proj.years.forEach(function (r) {
if (r.phase !== 'draw' && r.age % 5 !== 0) return; n++; 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 += '<tr class="' + (r.unfunded > 1 ? 'dep' : '') + '"><td>' + r.age + '</td><td>' + k(r.rrspBal) + '</td><td>' + k(r.tfsaBal) + tbl += '<tr class="' + (r.unfunded > 1 ? 'dep' : '') + '"><td>' + r.age + '</td><td>' + k(r.rrspBal) + '</td><td>' + k(r.tfsaBal) +
'</td><td>' + k(r.nonregBal) + '</td><td><b>' + k(r.totalBal) + '</b></td><td>' + k(r.rrifWithdrawal + r.nonregW + r.tfsaW) + '</td><td>' + k(r.nonregBal) + '</td><td><b>' + k(r.totalBal) + (r.mixed ? ' †' : '') + '</b></td><td>' + k(r.rrifWithdrawal + r.nonregW + r.tfsaW) +
'</td><td>' + k(r.cpp) + '</td><td>' + k(r.oasGross - r.oasClawback) + '</td><td>' + k(r.gis) + '</td><td>' + k(r.tax + r.oasClawback) + '</td><td>' + k(r.cpp) + '</td><td>' + k(r.oasGross - r.oasClawback) + '</td><td>' + k(r.gis) + '</td><td>' + k(r.tax + r.oasClawback) +
'</td><td>' + (r.unfunded > 1 ? k(r.unfunded) : '—') + '</td></tr>'; '</td><td>' + (r.unfunded > 1 ? k(r.unfunded) : '—') + '</td></tr>';
}); });
$('#yr-table').innerHTML = tbl; $('#yr-summary').innerHTML = t('r.yearlySummary', { n: n }); $('#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; var m2 = fmtMoney2;
$('#stamp').textContent = t('r.stamp', { $('#stamp').textContent = t('r.stamp', {
+37 -10
View File
@@ -77,11 +77,18 @@
} }
/* ---- OAS recovery tax (clawback) ---- */ /* ---- 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) { function clawback(netIncome) {
return 0.15 * Math.max(0, (netIncome || 0) - V('oasClawbackThreshold')); 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. // 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). // gis = max(0, gisMax − 0.5 × max(0, incomeExOAS − 5000)) (first $5k employment exemption approximated).
// Returns ANNUAL $, non-taxable. // Returns ANNUAL $, non-taxable.
@@ -174,6 +181,11 @@
var o = {}, k; var o = {}, k;
for (k in DEFAULTS) o[k] = DEFAULTS[k]; for (k in DEFAULTS) o[k] = DEFAULTS[k];
for (k in (inp || {})) if (inp[k] !== undefined && inp[k] !== null) o[k] = inp[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; return o;
} }
@@ -233,8 +245,8 @@
var taxable1 = pre + W; var taxable1 = pre + W;
var opts1 = { pensionIncome: (a >= 65 && a >= psStart ? (p.pensionAnnual || 0) : 0) + (a >= 65 ? W : 0) }; var opts1 = { pensionIncome: (a >= 65 && a >= psStart ? (p.pensionAnnual || 0) : 0) + (a >= 65 ? W : 0) };
var t1 = tax(taxable1, p.province, a, opts1); var t1 = tax(taxable1, p.province, a, opts1);
var c1 = clawback(taxable1); var c1 = oasGross > 0 ? Math.min(clawback(taxable1), oasGross) : 0; // clawback only in years OAS is received, capped at OAS paid
var g = gis(taxable1 - oasGross, 'single'); 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 cash1 = pre + W + g - t1.total - c1;
var n = 0, t = 0, taxable = taxable1, tt = t1, cc = c1, opts = opts1; var n = 0, t = 0, taxable = taxable1, tt = t1, cc = c1, opts = opts1;
var rem = need - cash1; var rem = need - cash1;
@@ -244,13 +256,13 @@
taxable = taxable1 + 0.5 * n; taxable = taxable1 + 0.5 * n;
opts = { pensionIncome: opts1.pensionIncome }; opts = { pensionIncome: opts1.pensionIncome };
tt = tax(taxable, p.province, a, opts); tt = tax(taxable, p.province, a, opts);
cc = clawback(taxable); cc = oasGross > 0 ? Math.min(clawback(taxable), oasGross) : 0; // same rule: no OAS received → no clawback; capped at OAS paid
g = gis(taxable - oasGross, 'single'); g = a >= 65 ? gis(taxable - oasGross, 'single') : 0;
rem = need - (pre + W + g + n - tt.total - cc); rem = need - (pre + W + g + n - tt.total - cc);
} }
if (rem > 0 && tfsa > 0) { if (rem > 0 && tfsa > 0) {
t = Math.min(tfsa, rem); 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); rem = need - (pre + W + g + n + t - tt.total - cc);
} }
var cash = 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) { if (Math.abs(shift) > 1) {
var tA = tax(ra.taxable - shift, p.province, yr, { pensionIncome: pa - shift }); 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 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; } if (newTax + newClaw < taxSum + clawSum) { taxSum = newTax; clawSum = newClaw; }
} }
} }
row.tax = taxSum; row.oasClawback = clawSum; 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 incA = ra.taxable - ra.oasGross, incB = rb.taxable - rb.oasGross;
var gisSum; var gisSum;
if (ra.phase === 'draw' && rb.phase === 'draw' && ra.age >= 65 && rb.age >= 65) { if (ra.phase === 'draw' && rb.phase === 'draw' && ra.age >= 65 && rb.age >= 65) {
gisSum = gis(incA + incB, 'couple'); gisSum = gis(incA + incB, 'couple');
} else { } 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; 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); 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; cash = ra.cash + rb.cash - (ra.gis + rb.gis) + gisSum + (ra.tax + rb.tax) - taxSum;
row.cash = Math.min(cash, row.need); 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); 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; totalTax += taxSum; totalClawback += clawSum;
merged.push(row); merged.push(row);
} }
@@ -445,6 +471,7 @@
recommendations: recommendations, recommendations: recommendations,
pensionStartAge: pensionStartAge, pensionStartAge: pensionStartAge,
pensionBridgeAnnual: pensionBridgeAnnual, pensionBridgeAnnual: pensionBridgeAnnual,
fillDefaults: fillDefaults,
defaults: DEFAULTS, defaults: DEFAULTS,
constants: C constants: C
}; };
+6 -4
View File
@@ -93,7 +93,7 @@
title: 'Monthly savings into RRSP', hint: 'Your contribution + any employer match.', title: 'Monthly savings into RRSP', hint: 'Your contribution + any employer match.',
you: 'You contribute $/mo', match: 'Employer match $/mo' 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.' }, spend: { title: 'Planned annual spending in retirement', hint: 'Today’s dollars, after tax. Be honest — this is the number that decides everything.' },
cppstart: { cppstart: {
title: 'Start CPP at…', hint: 'Age {age}: {adj}', title: 'Start CPP at…', hint: 'Age {age}: {adj}',
@@ -247,7 +247,8 @@
}, },
t: { t: {
age: 'Age', rrsp: 'RRSP', tfsa: 'TFSA', nonreg: 'Non-reg', total: 'Total', wdr: 'Withdraw', 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: { d: {
main: '<b>Estimates only — not financial advice.</b> 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.', main: '<b>Estimates only — not financial advice.</b> 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.', title: 'Épargne mensuelle au REER', hint: 'Votre cotisation + la contrepartie de l\'employeur.',
you: 'Vous cotisez', match: '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.' }, 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: { cppstart: {
title: 'Débuter la RPC à…', hint: 'À {age} ans : {adj}', title: 'Débuter la RPC à…', hint: 'À {age} ans : {adj}',
@@ -507,7 +508,8 @@
}, },
t: { t: {
age: 'Âge', rrsp: 'REER', tfsa: 'CELI', nonreg: 'Non enreg.', total: 'Total', wdr: 'Retrait', 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: { d: {
main: '<b>Estimations seulement — il ne s\'agit pas de conseils financiers.</b> 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.', main: '<b>Estimations seulement — il ne s\'agit pas de conseils financiers.</b> 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.',
+2 -1
View File
@@ -83,7 +83,7 @@
<label><span data-i18n="r.wiCtrb">Monthly savings</span> <output id="wi-ctrb-o"></output> <label><span data-i18n="r.wiCtrb">Monthly savings</span> <output id="wi-ctrb-o"></output>
<input type="range" id="wi-ctrb" min="0" max="5000" step="50"></label> <input type="range" id="wi-ctrb" min="0" max="5000" step="50"></label>
<label><span data-i18n="r.wiRet">Retirement age</span> <output id="wi-ret-o"></output> <label><span data-i18n="r.wiRet">Retirement age</span> <output id="wi-ret-o"></output>
<input type="range" id="wi-ret" min="55" max="72" step="1"></label> <input type="range" id="wi-ret" min="40" max="72" step="1"></label>
<label><span data-i18n="r.wiCpp">CPP starts at</span> <output id="wi-cpp-o"></output> <label><span data-i18n="r.wiCpp">CPP starts at</span> <output id="wi-cpp-o"></output>
<input type="range" id="wi-cpp" min="60" max="70" step="1"></label> <input type="range" id="wi-cpp" min="60" max="70" step="1"></label>
</div> </div>
@@ -92,6 +92,7 @@
<details class="yearly"> <details class="yearly">
<summary id="yr-summary">Year-by-year detail (<span id="yr-n">0</span> years)</summary> <summary id="yr-summary">Year-by-year detail (<span id="yr-n">0</span> years)</summary>
<div class="tbl-wrap"><table id="yr-table"></table></div> <div class="tbl-wrap"><table id="yr-table"></table></div>
<p class="cap" id="yr-mixed-note" hidden></p>
</details> </details>
<div class="disclaimer"> <div class="disclaimer">
+86
View File
@@ -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 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)); 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"); console.log("\nRESULT: " + pass + " pass, " + fail + " fail");
process.exit(fail ? 1 : 0); process.exit(fail ? 1 : 0);
+9
View File
@@ -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' }) }); 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()); 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 + ')'); console.log(fails ? '\nI18N-FAIL: ' + fails + ' of ' + total : '\nI18N-OK all i18n checks passed (' + total + ')');
process.exit(fails ? 1 : 0); process.exit(fails ? 1 : 0);
+73 -4
View File
@@ -65,13 +65,14 @@ function buildShim() {
global.confirm = () => true; global.confirm = () => true;
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
} }
function load(lang, psState) { function load(lang, psState, patch) {
buildShim(); buildShim();
global.window.RetireEngine = require(path.join(SITE, 'engine', 'engine.js')); global.window.RetireEngine = require(path.join(SITE, 'engine', 'engine.js'));
global.window.RetireCanada = require(path.join(SITE, 'engine', 'canada.js')); global.window.RetireCanada = require(path.join(SITE, 'engine', 'canada.js'));
global.window.RetirePS = require(path.join(SITE, 'engine', 'pspension.js')); global.window.RetirePS = require(path.join(SITE, 'engine', 'pspension.js'));
// persisted state in the target language: fresh answers, dropped at the quiz start // 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 }); 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, 'i18n.js'), 'utf8'));
eval(fs.readFileSync(path.join(SITE, 'app.js'), 'utf8')); eval(fs.readFileSync(path.join(SITE, 'app.js'), 'utf8'));
} }
@@ -165,6 +166,9 @@ const EN = {
g4Scen: /Current plan/, g4Scen: /Current plan/,
panel: /Savings at retirement/, panel: /Savings at retirement/,
wiCtrb: /\/mo/, wiCtrb: /\/mo/,
retireStep: /Retirement age/,
fireHint: /Early retirement \(FIRE\) supported/,
mixedNote: /One partner is retired/,
}; };
const FR = { const FR = {
title: /Où habitez-vous/, title: /Où habitez-vous/,
@@ -182,6 +186,9 @@ const FR = {
g4Scen: /Plan actuel/, g4Scen: /Plan actuel/,
panel: /Épargne à la retraite/, panel: /Épargne à la retraite/,
wiCtrb: /\/mois/, 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. */ /* PS module flow: federal public servant answers → results card + chart series, per language. */
@@ -342,16 +349,78 @@ function runPsQuiz(lang, done) {
}, 400); }, 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', { run('en', EN, () => run('fr', FR, () => runPs('en', {
psCard: /federal public service pension/, psAt: /60,000/, psFrom: /46,012\.50/, psCard: /federal public service pension/, psAt: /60,000/, psFrom: /46,012\.50/,
psBridge: /13,987\.50/, psSeries: /Public service pension/, psInsight: /covers about/ psBridge: /13,987\.50/, psSeries: /Public service pension/, psInsight: /covers about/
}, () => runPs('fr', { }, () => runPs('fr', {
psCard: /pension de la fonction publique fédérale/, psAt: /60[\s\u00a0\u202f]000/, psFrom: /46[\s\u00a0\u202f]012,50/, 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/ psBridge: /13[\s\u00a0\u202f]987,50/, psSeries: /Pension de la fonction publique/, psInsight: /couvre environ/
}, () => runPsQuiz('en', () => runPsQuiz('fr', () => { }, () => 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'))); 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'))); 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 + ')'); console.log(fails ? '\nSMOKE-FAIL: ' + fails + ' of ' + total : '\nSMOKE-OK all ui smoke checks passed (' + total + ')');
process.exit(fails ? 1 : 0); process.exit(fails ? 1 : 0);
})))))) }))))))))))
; ;