- The peak limit's Why and plan block need a real power request (floor above 0 W), not a blind or floor-limited guard. An Off battery gets the peak block only while it gives power. Why no longer says every battery gives. - test_batteries_screen pins the fixture clock: unpinned it failed every morning. New checks cover the trade and cycle filters, the blind guard and an Off battery that A covers alone. - Settings keeps only a picked view; a link to Installer is not remembered. - Browser check: badges, unsaved change across views, locked value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
9.7 KiB
Python
234 lines
9.7 KiB
Python
"""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_<STATE> = 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()
|