"""UX-13 P3: the headless-browser check of the Batteries screen and Settings, Simple. Run by `CHECK=1 tools/ux13_screenshots.sh`, like the screenshots. For every battery-mode fixture (tools/ux13_demo.py) it opens both screens at phone and desktop width, light and dark, in English and Dutch, and fails on a page error, a console error or a sideways scroll. Then it clicks the switch and the mode list on both screens and checks what was posted and what the cards show after. python3 ux13_browser_check.py # DEMO_ = the fixture's URL """ import json import os import sys from playwright.sync_api import sync_playwright STATES = ("running", "modes", "off", "off_peak", "refusal", "maint") SIZES = {"phone": {"width": 390, "height": 844}, "desktop": {"width": 1000, "height": 900}} FAILS = [] def url(state): return os.environ[f"DEMO_{state.upper()}"] def check(name, cond): print(f" {'ok ' if cond else 'FAIL'} {name}") if not cond: FAILS.append(name) def open_page(browser, state, route, *, size, scheme, lang): ctx = browser.new_context(locale=lang, viewport=SIZES[size], color_scheme=scheme, reduced_motion="reduce") page = ctx.new_page() errors = [] page.on("pageerror", lambda e: errors.append(f"pageerror {e}")) page.on("console", lambda m: errors.append(f"console {m.text}") if m.type == "error" else None) page.goto(url(state) + route) page.wait_for_load_state("networkidle") page.wait_for_timeout(1200) return ctx, page, errors def sweep(browser): n = 0 for state in STATES: for route in ("#batteries", "#settings"): for size in SIZES: for scheme in ("light", "dark"): for lang in ("en-GB", "nl-BE"): ctx, page, errors = open_page( browser, state, route, size=size, scheme=scheme, lang=lang) wide = page.evaluate( "document.documentElement.scrollWidth > " "document.documentElement.clientWidth") if route == "#batteries": cards = page.locator("#bcards .bcard").count() texts = page.locator("#bcards dd").all_inner_texts() ok = cards == 2 and all(t.strip() for t in texts) else: ok = (page.locator("#simple .card").count() >= 3 and page.locator("#groups").is_hidden()) where = f"{state} {route} {size} {scheme} {lang}" if errors or wide or not ok: FAILS.append(f"{where}: errors={errors} " f"wide={wide} drawn={ok}") print(f" FAIL {where}: {errors} wide={wide} " f"drawn={ok}") n += 1 ctx.close() check(f"{n} views: 0 page errors, 0 console errors, no sideways scroll, " f"every card drawn", not [f for f in FAILS if " #" in f]) def posts(page): sent = [] page.on("request", lambda r: sent.append(json.loads(r.post_data)) if r.url.endswith("/action") and r.method == "POST" else None) return sent def actions(browser): # The Batteries screen: switch B off, then set A to Leftover solar. ctx, page, errors = open_page(browser, "modes", "#batteries", size="desktop", scheme="light", lang="en-GB") sent = posts(page) page.locator("#bsw-c1").click() page.wait_for_timeout(1500) card_b = page.locator("#bcards .bcard").nth(1) check("Batteries: B's switch posts pack_use false, and B's card turns Off " "with its mode list greyed out", sent[-1:] == [{"action": "pack_use", "unit": "Battery B", "value": False}] and "off" in (card_b.get_attribute("class") or "") and page.locator("#bsel-c1").is_disabled() and "You switched it off." in card_b.inner_text()) page.locator("#bsel-c0").focus() page.locator("#bsel-c0").select_option("leftover_solar") page.wait_for_timeout(1500) check("...A's list posts pack_mode leftover_solar and the card keeps it", sent[-1:] == [{"action": "pack_mode", "unit": "Battery A", "value": "leftover_solar"}] and page.locator("#bsel-c0").input_value() == "leftover_solar" and "Leftover solar" in page.locator("#bcards .bcard").nth(0) .inner_text()) check("...the cheap-hours choice and the selling switch are greyed out", page.locator("#bsel-c0 option[disabled]").count() == 1 and page.locator("#bcards .bcard .f .switch[disabled]").count() == 2) check("...and the focus stays on the control that was used", page.evaluate("document.activeElement.id") == "bsel-c0") check("...with 0 page errors", not errors) ctx.close() # Settings, Simple: the same switch, and the Installer view is one tap. ctx, page, errors = open_page(browser, "modes", "#settings", size="phone", scheme="dark", lang="nl-BE") sent = posts(page) page.locator("#bsw-s1").click() page.wait_for_timeout(1500) check("Simple (NL, phone, dark): B's switch posts pack_use true and reads " "Aan", sent[-1:] == [{"action": "pack_use", "unit": "Battery B", "value": True}] and page.locator("#bsw-s1").get_attribute("aria-checked") == "true") page.locator("#set-view [data-v=installer]").click() page.wait_for_timeout(500) check("...Installer shows the groups and hides Simple", page.locator("#groups").is_visible() and page.locator("#simple").is_hidden()) check("...with 0 page errors", not errors) ctx.close() def switching(browser): # Review: an unsaved change survives the view switch both ways, the # Installer's locked values still need "Change", and only the person's # own pick of a view is remembered. ctx, page, errors = open_page(browser, "modes", "#settings", size="desktop", scheme="light", lang="en-GB") chips = [c.locator(".chip").first.inner_text() for c in page.locator("#simple > .card").all()] check(f"Simple's badges: the peak limit and shadow mode need a restart, " f"the batteries apply at once ({chips})", chips == ["Applies after a restart", "Applies right away", "Applies after a restart"]) num = page.locator("#simple .card input.num").first num.fill("4.2") num.dispatch_event("change") page.wait_for_timeout(600) n1 = page.locator("#sb-text").inner_text() page.locator("#set-view [data-v=installer]").click() page.wait_for_timeout(600) val = page.locator("#groups [data-key=peak_cap_w] input.num").input_value() n2 = page.locator("#sb-text").inner_text() locked = (page.locator("#groups [data-key=peak_soc_floor] input").count(), page.locator("#groups [data-key=peak_soc_floor] button.link") .count()) page.locator("#set-view [data-v=simple]").click() page.wait_for_timeout(600) back = page.locator("#simple .card input.num").first.input_value() n3 = page.locator("#sb-text").inner_text() check("an unsaved peak limit survives Simple -> Installer -> Simple, and " "the save bar counts it once", n1.startswith("1 ") and n2 == n1 and n3 == n1 and val == "4.2" and back == "4.2" and page.locator("#savebar").is_visible()) check("...the Installer's locked value has no input until 'Change'", locked == (0, 1)) ctx.close() ctx, page, errors = open_page(browser, "running", "#batteries", size="desktop", scheme="light", lang="en-GB") page.locator("#batt-off a").click() page.wait_for_timeout(800) page.reload() page.wait_for_load_state("networkidle") page.wait_for_timeout(1200) check("a jump to Installer from a link is not remembered: Settings " "opens on Simple again", page.locator("#simple").is_visible() and page.locator("#groups").is_hidden() and not errors) ctx.close() def modes_off(browser): ctx, page, errors = open_page(browser, "running", "#batteries", size="phone", scheme="light", lang="en-GB") page.locator("#batt-off a").click() page.wait_for_timeout(1500) check("modes off: the card's button opens Settings on the Installer view", page.url.endswith("#settings") and page.locator("#groups") .is_visible() and not errors) ctx.close() def month_peak(browser): # The fixture has no month peak yet, so give /status one on the way in. ctx = browser.new_context(locale="en-GB", viewport=SIZES["desktop"]) page = ctx.new_page() def with_peak(route): r = route.fetch() body = r.json() body["month_peak"]["peak_w"] = 2860.0 route.fulfill(response=r, json=body) page.route("**/status*", with_peak) page.goto(url("modes") + "#settings") page.wait_for_load_state("networkidle") page.wait_for_timeout(1500) check("Simple: the peak card shows this month's peak from /status", "This month's peak so far: 2.9 kW." in page.locator("#simple") .inner_text()) ctx.close() def main(): with sync_playwright() as p: b = p.chromium.launch() sweep(b) actions(b) month_peak(b) switching(b) modes_off(b) b.close() if FAILS: print(f"{len(FAILS)} FAILED") sys.exit(1) print("all passed") if __name__ == "__main__": main()