Files
retire-planner/audit/lang_right_capture.py
T
krisf 043fa8a683 Header: right-align EN|FR toggle in .hdr-in (top-right of page)
One CSS line: .lang-toggle{order:99;margin-left:auto}. Spec's literal order:99 alone
failed its own <=2px right-edge acceptance (gap 45.6-467.6px, 18/24 cases — order
reorders, .hdr-in is left-packed; geo-literal-order99.json), so margin-left:auto on
the toggle absorbs the free space; pill/logo untouched (spec-forbidden zone respected).

Battery: A1 gap=0px + rightmost + nothing-past-left in 24/24 (320/360/390/1280 x EN/FR
x intro/quiz/results); A2 header height unchanged 56/66px; A3 320px no overflow/wrap,
FR logo ellipsis intact, elementFromPoint hit-tests lang buttons + Start over pass;
A4 FR/EN clicks switch lang+title+aria-pressed; A5 24 screenshots before/after; A6
engine 36 + i18n 48 + smoke 92 + ps-pension 70 green; A7 desktop pixdiff header-strip-
only, 0 px below header at every device, self-jitter bit-identical; A8 DEPLOY-OK,
origin marker=1 + byte-identical CSS, off-LAN troublebox 200+marker+rule OFFLAN-OK.
2026-10-09 13:32:40 -04:00

222 lines
11 KiB
Python

#!/usr/bin/env python3
"""Header lang-toggle right-align audit capture (spec 2026-10-09 header-lang-toggle-rightalign).
Usage: python3 audit/lang_right_capture.py BASE OUTDIR TAG [--desktop-twice]
BASE e.g. http://127.0.0.1:8899/
OUTDIR audit/lang-right/before | audit/lang-right/after
TAG 'before' | 'after'
--desktop-twice : also write OUTDIR/../<basename>d2 desktop copies (pixdiff self-jitter pair)
Writes OUTDIR/geo-TAG.json + screenshots:
{dev}-{lang}-{screen}.png for dev in se(320) pixel(360) iphone14(390) desk(1280x900),
lang en|fr, screen intro|quiz|results.
Acceptance measured in-page:
A1 .lang-toggle rightmost in .hdr-in: toggle.right within 2px of (hdrIn.right - paddingRight);
no other visible .hdr-in child extends past the toggle's left edge.
A2 .hdr-in height <= 56px at <=700px widths.
A3 at 320px: hdr-in no overflow, no wrap (single row), logo truncated with ellipsis,
toggle + Start over fully visible + elementFromPoint hit-tests.
Prints per-case PASS/FAIL lines and CAPTURE-OK / CAPTURE-FAIL.
"""
import json, os, sys, shutil
from playwright.sync_api import sync_playwright
BASE = sys.argv[1].rstrip('/') + '/'
OUTD = sys.argv[2]
TAG = sys.argv[3]
TWICE = '--desktop-twice' in sys.argv
os.makedirs(OUTD, exist_ok=True)
CHROME = '/home/ubuntu/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome'
LS = 'retire-planner-v1'
SINGLE = {
"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 = [
('desk', 1280, 900, 1.0, None),
('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'),
]
SCREENS = [('intro', 'intro', 0), ('quiz', 'quiz', 0), ('results', 'results', 14)]
GEO_JS = r"""() => {
const r = e => { const b = e.getBoundingClientRect(); return {l:+b.left.toFixed(1), t:+b.top.toFixed(1),
r:+b.right.toFixed(1), b:+b.bottom.toFixed(1), w:+b.width.toFixed(1), h:+b.height.toFixed(1)}; };
const vis = e => { const s = getComputedStyle(e); return s.display !== 'none' && s.visibility !== 'hidden' && !e.hidden; };
const hdrIn = document.querySelector('.hdr-in');
const hi = r(hdrIn), cs = getComputedStyle(hdrIn);
const kids = [...hdrIn.children].map(e => ({
tag: e.tagName.toLowerCase(),
id: e.id || null,
cls: e.className || null,
rect: r(e),
visible: vis(e),
order: getComputedStyle(e).order,
}));
const tog = kids.find(k => (k.cls || '').includes('lang-toggle'));
const padR = parseFloat(cs.paddingRight) || 0;
const visibleKids = kids.filter(k => k.visible);
const maxRightOther = visibleKids.filter(k => k !== tog).reduce((m, k) => Math.max(m, k.rect.r), -1e9);
const pastToggleLeft = visibleKids.filter(k => k !== tog && k.rect.r > tog.rect.l + 0.5)
.map(k => (k.id || k.cls) + '@' + k.rect.r);
const rightGap = +((hi.r - padR) - tog.rect.r).toFixed(2);
// wrap detection: all visible children share one row (compare CENTER-Y, not top edges,
// because flex center-aligns children of differing heights on the same row)
const cy = visibleKids.map(k => k.rect.t + k.rect.h / 2);
const wrapped = cy.length > 0 && (Math.max(...cy) - Math.min(...cy)) > 6;
const logo = hdrIn.querySelector('.logo');
const lcs = logo ? getComputedStyle(logo) : null;
const hit = sel => {
const e = document.querySelector(sel);
if (!e || !vis(e)) return {present: !!e, visible: false};
const b = e.getBoundingClientRect();
const cx = Math.round(b.left + b.width / 2), cy = Math.round(b.top + b.height / 2);
const top = document.elementFromPoint(cx, cy);
return {present: true, visible: true, rect: r(e), inViewport: b.top >= 0 && b.bottom <= innerHeight && b.left >= 0 && b.right <= innerWidth,
hit: !!top && (top === e || e.contains(top) || top.contains(e)), hitTag: top ? top.tagName.toLowerCase() + (top.id ? '#' + top.id : '') : null};
};
const hdr = document.querySelector('.hdr');
return {
vw: innerWidth, vh: innerHeight,
lang: document.documentElement.lang,
title: document.title,
hdr: hdr ? r(hdr) : null,
hdrIn: hi, hdrInPadRight: padR, hdrInH: +hi.h.toFixed(1),
scrollW: hdrIn.scrollWidth, clientW: hdrIn.clientWidth,
docScrollW: document.documentElement.scrollWidth,
flexWrap: cs.flexWrap,
kids,
toggle: tog,
A1_rightGap: rightGap,
A1_rightGapOk: Math.abs(rightGap) <= 2,
A1_rightmost: tog.rect.r >= maxRightOther - 0.5,
A1_pastToggleLeft: pastToggleLeft,
A2_hdrH: +hi.h.toFixed(1),
wrapped,
overflow: hdrIn.scrollWidth > hdrIn.clientWidth + 1 || document.documentElement.scrollWidth > innerWidth + 1,
logo: logo ? {truncating: logo.scrollWidth > logo.clientWidth + 1, textOverflow: lcs.textOverflow,
ellipsisPossible: lcs.whiteSpace === 'nowrap' && lcs.overflow === 'hidden', rect: r(logo)} : null,
hits: {langEn: hit('#lang-en'), langFr: hit('#lang-fr'), restart: hit('#btn-restart')},
};
}"""
TOGGLE_JS = r"""async () => {
const g = s => document.querySelector(s);
const before = {lang: document.documentElement.lang, title: document.title,
pe: g('#lang-en').getAttribute('aria-pressed'), pf: g('#lang-fr').getAttribute('aria-pressed')};
g('#lang-fr').click();
await new Promise(r => setTimeout(r, 250));
const fr = {lang: document.documentElement.lang, title: document.title,
pe: g('#lang-en').getAttribute('aria-pressed'), pf: g('#lang-fr').getAttribute('aria-pressed')};
g('#lang-en').click();
await new Promise(r => setTimeout(r, 250));
const en = {lang: document.documentElement.lang, title: document.title,
pe: g('#lang-en').getAttribute('aria-pressed'), pf: g('#lang-fr').getAttribute('aria-pressed')};
return {before, fr, en,
frOk: fr.lang === 'fr' && fr.title !== before.title && fr.pe === 'false' && fr.pf === 'true',
backOk: en.lang === 'en' && en.title === before.title && en.pe === 'true' && en.pf === 'false'};
}"""
def set_state(page, screen, lang):
page.evaluate("""([ls, a, screen, lang]) => {
localStorage.setItem(ls, JSON.stringify({a: a, step: (screen==='intro'?0:14), screen: screen, lang: lang}));
}""", [LS, SINGLE, screen, lang])
def shot(page, path, full=False):
page.screenshot(path=path, full_page=full)
def verdicts(key, g, out):
"""Record acceptance verdicts for one case."""
narrow = g['vw'] <= 700
out.append({'case': key,
'A1_gap': g['A1_rightGap'], 'A1_gapOk': g['A1_rightGapOk'],
'A1_rightmost': g['A1_rightmost'], 'A1_pastLeft': g['A1_pastToggleLeft'],
'A2_hdrH': g['A2_hdrH'], 'A2_ok': (not narrow) or g['A2_hdrH'] <= 56,
'A3_overflow': g['overflow'], 'A3_wrapped': g['wrapped'],
'A3_logoTrunc': bool(g['logo'] and g['logo']['truncating']),
'A3_logoEllipsisOk': (not g['logo'] or not g['logo']['truncating'])
or (g['logo']['ellipsisPossible'] and g['logo']['textOverflow'] == 'ellipsis'),
'A3_hits': {k: (v.get('visible', False) and v.get('inViewport', False) and v.get('hit', False))
if k != 'restart' else ((not v.get('present')) or (not v.get('visible')) or
(v.get('inViewport') and v.get('hit')))
for k, v in g['hits'].items()},
'kidsOrder': [(k['id'] or k['cls'], k['order'], k['visible']) for k in g['kids']]})
print(f"[{key}] A1 gap={g['A1_rightGap']}px rightmost={g['A1_rightmost']} pastLeft={g['A1_pastToggleLeft']} | "
f"A2 h={g['A2_hdrH']} | A3 overflow={g['overflow']} wrapped={g['wrapped']} logoTrunc={bool(g['logo'] and g['logo']['truncating'])} | "
f"hdrH={g['hdrInH']}")
geo = {}
report = []
fails = []
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:
opts = {'viewport': {'width': w, 'height': h}, 'device_scale_factor': dsf}
if ua:
opts.update(is_mobile=True, has_touch=True, user_agent=ua)
ctx = browser.new_context(**opts)
page = ctx.new_page()
page.goto(BASE, wait_until='networkidle')
for sname, screen, _step in SCREENS:
for lang in ('en', 'fr'):
key = f'{dev}-{lang}-{sname}'
set_state(page, screen, lang)
page.reload(wait_until='networkidle')
page.wait_for_timeout(1600 if sname == 'results' else 700)
shot(page, os.path.join(OUTD, key + '.png'))
g = page.evaluate(GEO_JS)
geo[key] = g
verdicts(key, g, report)
if TWICE and dev == 'desk' and sname in ('intro', 'results'):
shot(page, os.path.join(OUTD, key + '.d2.png'))
if dev == 'iphone14':
# toggle test starts from a defined EN state (previous loop iter may leave FR)
set_state(page, 'intro', 'en')
page.reload(wait_until='networkidle')
page.wait_for_timeout(400)
geo['toggle-click'] = page.evaluate(TOGGLE_JS)
print('toggle-click:', json.dumps(geo['toggle-click']))
ctx.close()
browser.close()
# ---- verdict summary ----
for row in report:
dev = row['case'].split('-')[0]
narrow = dev in ('se', 'pixel', 'iphone14')
ok = (row['A1_gapOk'] and row['A1_rightmost'] and not row['A1_pastLeft']
and (not narrow or row['A2_ok']))
if dev == 'se':
ok = ok and not row['A3_overflow'] and not row['A3_wrapped'] and row['A3_logoEllipsisOk'] and all(row['A3_hits'].values())
if not ok:
fails.append(row['case'])
tc = geo.get('toggle-click', {})
if tc and not (tc.get('frOk') and tc.get('backOk')):
fails.append('toggle-click')
with open(os.path.join(OUTD, f'geo-{TAG}.json'), 'w') as f:
json.dump({'geo': geo, 'report': report}, f, indent=1, ensure_ascii=False)
if TWICE: # desktop pair for self-jitter baseline lives beside the main dir
d2 = os.path.join(os.path.dirname(OUTD.rstrip('/')), os.path.basename(OUTD.rstrip('/')) + '2')
os.makedirs(d2, exist_ok=True)
for f_ in os.listdir(OUTD):
if f_.endswith('.d2.png'):
shutil.move(os.path.join(OUTD, f_), os.path.join(d2, f_[:-len('.d2.png')] + '.png'))
print('cases:', len(geo) - 1, '| failures:', fails if fails else 'NONE')
print('CAPTURE-OK' if not fails else 'CAPTURE-FAIL')
sys.exit(0 if not fails else 1)