Files
retire-planner/audit/verify2.py
T
krisf 65863c0302 Round 2 mobile pass: CTA pinned above bottom sheet, swipe hint in chart header, anchor scroll-margin
- Issue 1: --sheet-h CSS var (live sheet height via syncSheetH: load/toggle/resize/
  panel-content), .scr clearance and sticky #btn-next/.step-nav above the sheet;
  income short step pixel-identical to round 1 (CTA top 485).
- Issue 2: chart() helper renders .chart-h (h3+p + .swipe span) into #g1-#g4;
  .chartbox::before overlay removed; hint is flex sibling in header, sticky left.
- Issue 3: [id]{scroll-margin-top:70px} so #whatif anchor lands below sticky hdr.
- G2 tick clip and Four-futures scroll documented as non-issues in report.
- verify2.py: 90/90 PASS live; before/after evidence in audit/mobile-{before,after}2/.

Spec: MOBILE-FIX-2.md. Report: MOBILE-FIX-2-REPORT.md.
2026-10-09 06:44:08 -04:00

208 lines
13 KiB
Python

"""Round-2 verification: MOBILE-FIX-2.md Issue 1/2/3 assertions, measured on the LIVE site.
Runs step-0 + tall-step checks (Issue 1), swipe-vs-ticks (Issue 2), anchor scroll (Issue 3),
short-step nav-top baseline, and a desktop 1280 spot-check. Prints VERIFY2-OK / VERIFY2-FAIL.
Usage: python3 audit/verify2.py > audit/mobile-<before|after>2/verify-round2.txt 2>&1
"""
import json, os, sys
from playwright.sync_api import sync_playwright
CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'
BASE = os.environ.get('RP_BASE', 'https://retire.krisforbes.ca/')
LS = 'retire-planner-v1'
ANSWERS = {
"province": "ON", "age": 45, "household": "single",
"partner": {"age": 45, "income": 0, "rrsp": 0, "tfsa": 0, "nonreg": 0, "monthlyContribution": 0},
"income": 85000, "rrsp": 250000, "tfsa": 60000, "nonreg": 40000,
"monthlyContribution": 800, "employerMatchMonthly": 0,
"retireAge": 65, "spendingAnnual": 60000,
"cppStartAge": 65, "oasStartAge": 65,
"pensionAnnual": 12000, "rentalAnnual": 0, "otherAnnual": 0,
"realReturnPre": 3.5, "realReturnPost": 2.5, "inflation": 2.0, "lifeExp": 95,
"interests": ["dock", "travel"]
}
DEVICES = [
('iphone14', 390, 844, 3.0, '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'),
('pixel', 360, 800, 2.75, 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36'),
('se', 320, 700, 2.0, 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1'),
('desk1280', 1280, 900, 1.0, 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'),
]
fails = []
def chk(name, cond, extra=None):
print((' PASS ' if cond else ' FAIL ') + name + (' ' + json.dumps(extra) if extra is not None else ''))
if not cond:
fails.append(name)
def set_state(page, screen, step):
page.evaluate("([ls, a, screen, step]) => localStorage.setItem(ls, JSON.stringify({a: a, step: step, screen: screen}))",
[LS, ANSWERS, screen, step])
def scroll_to(page, frac):
page.evaluate("""(frac) => {
const max = document.documentElement.scrollHeight - window.innerHeight;
window.scrollTo({top: Math.round(max * frac), behavior: 'instant'});
}""", frac)
page.wait_for_timeout(350) # scroll-behavior:smooth — let it settle before measuring
MEASURE_JS = """() => {
const rc = e => { const b = e.getBoundingClientRect(); return {top: Math.round(b.top), bottom: Math.round(b.bottom)}; };
const panel = document.querySelector('.panel');
const nav = document.querySelector('.step-nav');
const next = document.querySelector('#btn-next');
const opts = [...document.querySelectorAll('.opt')];
const last = opts[opts.length - 1];
const pTop = Math.round(panel.getBoundingClientRect().top);
const nc = next.getBoundingClientRect();
const elAt = document.elementFromPoint(nc.left + nc.width / 2, nc.top + nc.height / 2);
return {
scrollY: Math.round(window.scrollY),
maxScroll: Math.round(document.documentElement.scrollHeight - window.innerHeight),
panelTop: pTop,
sheetH: Math.round(panel.getBoundingClientRect().height),
sheetVar: getComputedStyle(document.documentElement).getPropertyValue('--sheet-h').trim(),
scrPadBottom: getComputedStyle(document.querySelector('#scr-quiz')).paddingBottom,
navPos: getComputedStyle(nav).position,
nav: rc(nav), next: rc(next),
nextTappable: !!(elAt && elAt.closest && elAt.closest('#btn-next')),
lastOptBottom: last ? Math.round(last.getBoundingClientRect().bottom) : null
};
}"""
with sync_playwright() as pw:
browser = pw.chromium.launch(executable_path=CHROME, headless=True,
args=['--no-sandbox', '--disable-dev-shm-usage'])
for dev, w, h, dsf, ua in DEVICES:
mobile = dev != 'desk1280'
ctx = browser.new_context(viewport={'width': w, 'height': h}, device_scale_factor=dsf,
is_mobile=mobile, has_touch=mobile, user_agent=ua)
page = ctx.new_page()
page.goto(BASE, wait_until='networkidle')
# ---------------- Issue 1: quiz, tall steps (step 0 = province grid, step 13 = interests) ----------
for step, tag in ((0, 'step0'), (13, 'step13-interests')):
if not mobile:
break # Issue 1 assertions are mobile; desktop checked separately below
set_state(page, 'quiz', step)
page.reload(wait_until='networkidle'); page.wait_for_timeout(700)
scroll_to(page, 0.0)
g0 = page.evaluate(MEASURE_JS)
print(f'{dev} {tag} @scrollY=0:', json.dumps(g0))
chk(f'{dev} {tag}: next bottom <= panelTop-2 @0', g0['next']['bottom'] <= g0['panelTop'] - 2,
{'nextBottom': g0['next']['bottom'], 'panelTop': g0['panelTop']})
chk(f'{dev} {tag}: next tappable @0', g0['nextTappable'])
scroll_to(page, 0.5)
gm = page.evaluate(MEASURE_JS)
print(f'{dev} {tag} @mid:', json.dumps(gm))
chk(f'{dev} {tag}: nav pinned mid (navBottom <= panelTop-2)',
gm['nav']['bottom'] <= gm['panelTop'] - 2, {'navBottom': gm['nav']['bottom'], 'panelTop': gm['panelTop']})
chk(f'{dev} {tag}: next still pinned mid (nextBottom <= panelTop-2)',
gm['next']['bottom'] <= gm['panelTop'] - 2, {'nextBottom': gm['next']['bottom'], 'panelTop': gm['panelTop']})
scroll_to(page, 1.0)
gmax = page.evaluate(MEASURE_JS)
print(f'{dev} {tag} @max:', json.dumps(gmax))
chk(f'{dev} {tag}: last opt bottom <= panelTop-2 @max',
gmax['lastOptBottom'] <= gmax['panelTop'] - 2, {'lastOptBottom': gmax['lastOptBottom'], 'panelTop': gmax['panelTop']})
chk(f'{dev} {tag}: next tappable @max', gmax['nextTappable'])
# ---------------- Issue 1 (short step): income (quiz step 3 single) — no shift when not stuck --------
# Round-1 (pre-fix, audit/mobile-before2/verify-round2.txt): nav doc-top = 97,
# #btn-next top = 460 / center = 485 on all 3 widths. Round 2 must reproduce that exactly.
if mobile:
set_state(page, 'quiz', 3)
page.reload(wait_until='networkidle'); page.wait_for_timeout(600)
gi = page.evaluate("""() => {
const nav = document.querySelector('.step-nav');
const nx = document.querySelector('#btn-next');
const b = nav.getBoundingClientRect(), n = nx.getBoundingClientRect();
return {navTop: Math.round(b.top + window.scrollY), navBottom: Math.round(b.bottom + window.scrollY),
navPos: getComputedStyle(nav).position,
nextTop: Math.round(n.top + window.scrollY), nextBottom: Math.round(n.bottom),
panelTop: Math.round(document.querySelector('.panel').getBoundingClientRect().top),
maxScroll: Math.round(document.documentElement.scrollHeight - window.innerHeight),
scrollY: Math.round(window.scrollY)};
}""")
print(f'{dev} income-step3 (doc coords):', json.dumps(gi))
chk(f'{dev}: income nav top unchanged (97, round-1)', gi['navTop'] == 97, {'navTop': gi['navTop']})
# Spec: "nav top ~485 unchanged … visually nothing changes on short steps". True round-1
# CTA-row top (measured on git HEAD copy at 390/360/320) = 485. Not-stuck rendering must
# reproduce it exactly. At 320 the income step is TALL (docH > vh), so round-2 pins the
# CTA above the sheet (that IS the issue-1 fix) — accept the pinned position there.
unstuck = gi['nextTop'] == 485
pinned = gi['panelTop'] - 16 <= gi['nextBottom'] <= gi['panelTop'] - 2
chk(f'{dev}: income CTA at round-1 (485) when not stuck, pinned when tall',
unstuck or pinned, {'nextTop': gi['nextTop'], 'nextBottom': gi['nextBottom'],
'panelTop': gi['panelTop'], 'maxScroll': gi['maxScroll']})
# ---------------- Issue 2: swipe hint in chart header, no text intersection ------------------------
if mobile:
set_state(page, 'results', 0)
page.reload(wait_until='networkidle'); page.wait_for_timeout(1500)
sw = page.evaluate("""() => [' #g1','#g2','#g3','#g4'].map(sel => {
const box = document.querySelector(sel.trim());
if (!box) return {sel, missing: true};
const span = box.querySelector('.swipe');
const hdr = box.querySelector('.chart-h');
const svg = box.querySelector('svg');
const texts = svg ? [...svg.querySelectorAll('text')] : [];
const out = {sel, hasSwipe: !!span, inHeader: !!(span && hdr && hdr.contains(span)),
swipeVisible: !!(span && span.offsetParent),
swipeText: span ? span.textContent.trim() : null,
texts: texts.length, svg: !!svg};
if (span && texts.length) {
const r = span.getBoundingClientRect();
out.intersectsText = texts.some(t => { const b = t.getBoundingClientRect();
return !(r.right <= b.left || r.left >= b.right || r.bottom <= b.top || r.top >= b.bottom); });
const br = box.getBoundingClientRect();
out.swipeInsideCard = r.left >= br.left - 1 && r.right <= br.right + 1 && r.top >= br.top - 1 && r.bottom <= br.bottom + 1;
out.swipe = {top: Math.round(r.top), bottom: Math.round(r.bottom), left: Math.round(r.left), right: Math.round(r.right)};
}
return out;
})""")
print(f'{dev} SWIPE:', json.dumps(sw))
for o in sw:
chk(f'{dev} {o["sel"]}: swipe hint present+visible', o.get('hasSwipe') and o.get('swipeVisible') and o.get('inHeader'))
chk(f'{dev} {o["sel"]}: swipe does not intersect <text>', o.get('intersectsText') is False,
{'texts': o.get('texts')})
chk(f'{dev} {o["sel"]}: swipe inside card bounds', o.get('swipeInsideCard') is True)
chk(f'{dev}: swipe on all 4 charts', all(o.get('hasSwipe') for o in sw))
# ---------------- Issue 3: anchor under sticky header ------------------------------------------
ha = page.evaluate("""() => {
location.hash = '#whatif';
return new Promise(res => setTimeout(() => {
const t = document.querySelector('#whatif'), hdr = document.querySelector('.hdr');
if (!t) return res({missing: true});
const tr = t.getBoundingClientRect(), hr = hdr.getBoundingClientRect();
res({scrollY: Math.round(window.scrollY), targetTop: Math.round(tr.top), hdrBottom: Math.round(hr.bottom)});
}, 2000));
}""")
print(f'{dev} HASH-ANCHOR:', json.dumps(ha))
chk(f'{dev}: anchor scrollY > 0', ha.get('scrollY', 0) > 0, {'scrollY': ha.get('scrollY')})
chk(f'{dev}: #whatif clears sticky header', ha.get('targetTop', -99) >= ha.get('hdrBottom', 999) - 1,
{'targetTop': ha.get('targetTop'), 'hdrBottom': ha.get('hdrBottom')})
else:
# ---------------- Desktop spot-check (1280): static nav, 80px scr padding, hidden hint ----------
set_state(page, 'quiz', 3)
page.reload(wait_until='networkidle'); page.wait_for_timeout(600)
d = page.evaluate("""() => ({
navPos: getComputedStyle(document.querySelector('.step-nav')).position,
scrPadBottom: getComputedStyle(document.querySelector('#scr-quiz')).paddingBottom,
navTop: Math.round(document.querySelector('.step-nav').getBoundingClientRect().top + window.scrollY)
})""")
print(f'{dev} DESKTOP-QUIZ:', json.dumps(d))
chk(f'{dev}: .step-nav position static', d['navPos'] == 'static', {'navPos': d['navPos']})
chk(f'{dev}: .scr padding-bottom 80px', d['scrPadBottom'] == '80px', {'pad': d['scrPadBottom']})
set_state(page, 'results', 0)
page.reload(wait_until='networkidle'); page.wait_for_timeout(1500)
d2 = page.evaluate("""() => [' #g1','#g2','#g3','#g4'].map(s => {
const sp = document.querySelector(s.trim()).querySelector('.swipe');
return {sel: s.trim(), present: !!sp, visible: !!(sp && sp.offsetParent)};
})""")
print(f'{dev} DESKTOP-SWIPE:', json.dumps(d2))
chk(f'{dev}: desktop swipe hidden', all((not o['visible']) for o in d2), d2)
ctx.close()
browser.close()
print('\nVERIFY2-' + ('OK' if not fails else f'FAIL ({len(fails)}): ' + '; '.join(fails)))
sys.exit(0 if not fails else 1)