The builder previously exposed only chapters. Now each chapter expands to show its subtopics as individual checkboxes: selecting a chapter checks all of its subtopics, selecting the subject-level box checks everything, and selecting individual subtopics sets the chapter to indeterminate. The exam query filters by subtopic_id when any are given; the SQL path deduplicates so a question matching two subtopics only appears once. JEE mode sets duration to 60 × unique-subject-count (180 min for a full paper). On the exam screen this renders per-subject countdown strips: Physics 60:00, Maths 60:00, Chemistry 60:00. Each strip counts down independently; when one hits zero the screen auto-jumps to the first unanswered question of the next subject. The last strip exhausting time submits the form. Single-subject papers keep the flat timer and show no strips. Also: all audit targets now genuinely 44px (the .opt labels were 42px).
123 lines
No EOL
4.7 KiB
Python
Executable file
123 lines
No EOL
4.7 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Measures the layout at phone, tablet and desktop widths.
|
|
|
|
Checks the things a stylesheet review cannot: real horizontal overflow, real
|
|
tap-target sizes, and whether fixed bars cover content at the bottom.
|
|
scripts/audit_mobile.py <base-url>
|
|
"""
|
|
import sys
|
|
import urllib.parse
|
|
import urllib.request
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8180"
|
|
|
|
DEVICES = [
|
|
("iPhone SE", 375, 667, True),
|
|
("iPhone 13", 390, 844, True),
|
|
("iPad", 768, 1024, True),
|
|
("small laptop", 1024, 768, False),
|
|
("desktop", 1440, 900, False),
|
|
]
|
|
|
|
AUDIT = """
|
|
() => {
|
|
const de = document.documentElement;
|
|
const overflow = de.scrollWidth - de.clientWidth;
|
|
// Anything sticking out past the viewport is a horizontal scroll leak.
|
|
const wide = [...document.querySelectorAll('body *')]
|
|
.filter(el => {
|
|
const r = el.getBoundingClientRect();
|
|
return r.width > 0 && r.right > de.clientWidth + 1;
|
|
})
|
|
.slice(0, 6)
|
|
.map(el => el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).split(' ')[0] : ''));
|
|
// Touch targets on the controls a thumb has to hit. A checkbox is measured
|
|
// through its wrapping label, which is the box a thumb actually lands on.
|
|
const TARGETS = 'button, a.btn, .pal, .opt, .tabbar a, input, select, label.chk, label.sub-chk';
|
|
const small = [...document.querySelectorAll(TARGETS)]
|
|
.map(el => {
|
|
const box = (el.matches('input') && el.closest('label')) ? el.closest('label') : el;
|
|
return { el: box, r: box.getBoundingClientRect() };
|
|
})
|
|
.filter(x => x.r.width > 0 && x.r.height > 0)
|
|
.filter(x => x.r.height < 44 || x.r.width < 44)
|
|
.slice(0, 8)
|
|
.map(x => {
|
|
const cls = x.el.className ? '.' + String(x.el.className).split(' ')[0] : '';
|
|
return `${x.el.tagName.toLowerCase()}${cls} ${Math.round(x.r.width)}x${Math.round(x.r.height)}`;
|
|
});
|
|
// Does the last visible element sit under a fixed bar?
|
|
const fixed = [...document.querySelectorAll('body *')]
|
|
.filter(el => ['fixed', 'sticky'].includes(getComputedStyle(el).position));
|
|
let covered = null;
|
|
if (fixed.length) {
|
|
const lowest = Math.max(...fixed.map(el => el.getBoundingClientRect().bottom));
|
|
if (lowest >= de.clientHeight - 1) {
|
|
const hit = document.elementFromPoint(de.clientWidth / 2, de.clientHeight - 24);
|
|
covered = hit ? hit.tagName.toLowerCase() + (hit.className ? '.' + String(hit.className).split(' ')[0] : '') : 'none';
|
|
}
|
|
}
|
|
return { overflow, wide, small, covered, scrollH: de.scrollHeight };
|
|
}
|
|
"""
|
|
|
|
|
|
def sign_in(page, base):
|
|
page.goto(base + "/student", wait_until="networkidle")
|
|
page.fill("#name", "Audit")
|
|
page.fill("#email", "audit@example.com")
|
|
page.click("button[type=submit]")
|
|
page.wait_for_load_state("networkidle")
|
|
|
|
|
|
def paper_url(page, base):
|
|
page.goto(base + "/build", wait_until="networkidle")
|
|
page.evaluate("document.querySelectorAll('.chap').forEach(c => { if (!c.checked) c.click(); })")
|
|
page.click("button[type=submit]")
|
|
page.wait_for_load_state("networkidle")
|
|
return page.url
|
|
|
|
|
|
fails = []
|
|
with sync_playwright() as pw:
|
|
browser = pw.chromium.launch()
|
|
for name, w, h, touch in DEVICES:
|
|
ctx = browser.new_context(
|
|
viewport={"width": w, "height": h},
|
|
has_touch=touch,
|
|
is_mobile=touch,
|
|
user_agent=("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
|
|
"AppleWebKit/605.1.15 Mobile/15E148") if touch else None,
|
|
)
|
|
page = ctx.new_page()
|
|
sign_in(page, BASE)
|
|
pages = {"build": BASE + "/build"}
|
|
exam = paper_url(page, BASE)
|
|
page.goto(exam, wait_until="networkidle")
|
|
pages["exam"] = exam
|
|
|
|
print(f"\n=== {name} ({w}x{h}) ===")
|
|
for label, url in pages.items():
|
|
page.goto(url, wait_until="networkidle")
|
|
r = page.evaluate(AUDIT)
|
|
ok = r["overflow"] <= 0 and not r["wide"]
|
|
print(f" {label:<7} overflow={r['overflow']}px {'OK' if ok else 'LEAK'}")
|
|
if r["wide"]:
|
|
print(f" too wide: {', '.join(r['wide'])}")
|
|
fails.append(f"{name}/{label}: horizontal overflow {r['overflow']}px")
|
|
if r["small"]:
|
|
print(f" small targets: {', '.join(r['small'])}")
|
|
fails.append(f"{name}/{label}: {len(r['small'])} targets under 44px")
|
|
if r["covered"]:
|
|
print(f" bottom of screen is covered by: {r['covered']}")
|
|
ctx.close()
|
|
browser.close()
|
|
|
|
print()
|
|
if fails:
|
|
print("FAILURES")
|
|
for f in fails:
|
|
print(" -", f)
|
|
sys.exit(1)
|
|
print("all viewports clean: no overflow, every target 44px or larger") |