engine: GIS 65+ floor, clawback only with OAS, couple mixed-row footnote, FIRE retire age 40
This commit is contained in:
1 parent
de451f0525
commit
a210e7edfc
10 files changed
+348
-24
No files matched your search
@@ -4,8 +4,8 @@
|
||||
"has_lang_toggle": true,
|
||||
"has_i18n_script": true,
|
||||
"i18n_js_fr_strings": true,
|
||||
"i18n_js_size": 28788,
|
||||
"checked_at": "2026-10-09T14:01:51Z",
|
||||
"i18n_js_size": 39689,
|
||||
"checked_at": "2026-10-09T19:52:48Z",
|
||||
"vantage": "trouble@100.96.20.61 (Midhurst, off-LAN)",
|
||||
"all_ok": true
|
||||
}
|
||||
@@ -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])
|
||||
Reference in new issue
Block a user