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.
222 lines
11 KiB
Python
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)
|