"""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])