Files
Project-SDE-WP-Suite/tests/sticky_bar_check.py
n.siegfried e3de3c7c00 T7.8 - B6: the wizard's actions ride the viewport, not the page
On the Constraints and Sequence steps the proposal's beside-the-fields actions
meant scrolling to save. The wizard's .step-navigation bar is now
position:sticky at the viewport bottom - the creator's sticky-bar pattern,
adapted rather than duplicated: sticky (not the creator's fixed) because the
bar lives inside the wizard's grid column, keeps its slot in the flow, and
therefore CANNOT obscure a field at any width - no padding arithmetic to get
wrong. Opaque background, top border and the shared --wp-shadow-sticky token
so content scrolling beneath it reads as beneath it.

The T4.4/B5 save-state indicator already mounted in this bar; it now rides the
viewport with the buttons, which is the "shows the save state" criterion.

Verification (each probe run alone): NEW tests/sticky_bar_check.py 12/12 - a
primary action inside the viewport on all 12 steps unscrolled at a 700px
viewport (short on purpose: both named steps genuinely overflow, asserted),
still visible fully scrolled, nothing obscured at 390px. Regression:
stepper_check ALL PASS.

Items: B6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:14:33 -07:00

165 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""Is Save reachable without scrolling, on every step? — B6, T7.8.
The proposal put actions beside the fields; on the Constraints and Sequence
steps that meant scrolling to save. The wizard's step-navigation bar is now
position:sticky at the viewport bottom - the creator's pattern, adapted to the
wizard's grid (sticky keeps its slot in the flow, so it cannot obscure a field
at any width). The T4.4 indicator already lives in the bar and rides along.
Driven at a deliberately short viewport (700px) so the tall steps genuinely
overflow - a bar that is only reachable because the page happens to fit would
pass a taller run and prove nothing.
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.5):
time.sleep(seconds)
BAR_JS = """(() => JSON.stringify((() => {
const bar = document.querySelector('.step-navigation');
if (!bar) return null;
const r = bar.getBoundingClientRect();
const cs = getComputedStyle(bar);
const btn = [...bar.querySelectorAll('button')].find(b => b.offsetParent !== null);
const br = btn ? btn.getBoundingClientRect() : null;
return {pos: cs.position, top: r.top, bottom: r.bottom,
btnVisible: !!br && br.top >= 0 && br.bottom <= innerHeight,
indicator: !!bar.querySelector('#wp-draft-status-sop')};
})()))()"""
def bar(page):
return json.loads(page.eval(BAR_JS))
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-bar-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1280, 700)
page.goto(base + "/work-package-suite.html?tab=sop")
dismiss_dialogs(page)
settle(2.5)
chk("the wizard boots", page.eval("typeof goToStep === 'function'"))
print("\n1. every step, top of page, no scrolling")
last = page.eval("LAST_STEP")
all_ok, detail = True, []
for n in range(1, int(last) + 1):
# Forward movement is gated on real input; the check is about layout,
# so walk the steps the way the rail allows after marking them visited.
page.eval("currentStep = %d; updateStepUI(); window.scrollTo(0,0)" % n)
settle(0.25)
b = bar(page)
ok = bool(b) and b["btnVisible"]
all_ok = all_ok and ok
if not ok:
detail.append((n, b))
chk("a primary action is inside the viewport on all %s steps, unscrolled" % last,
all_ok, ascii_(detail))
print("\n2. the two steps that caused B6")
for name, n in (("Sequence", 8), ("Constraints", 9)):
page.eval("currentStep = %d; updateStepUI(); window.scrollTo(0,0)" % n)
settle(0.3)
tall = page.eval("document.documentElement.scrollHeight > innerHeight")
b = bar(page)
chk("%s: the step overflows a 700px viewport, so the check means something" % name,
bool(tall))
chk("%s: the bar is stuck to the viewport bottom with its button visible" % name,
b and b["pos"] == "sticky" and b["btnVisible"], ascii_(b))
page.eval("window.scrollTo(0, document.documentElement.scrollHeight)")
settle(0.3)
b2 = bar(page)
chk("%s: still there at the bottom of the scroll" % name,
b2 and b2["btnVisible"], ascii_(b2))
chk("the T4.4 save-state indicator lives in the bar", bar(page)["indicator"])
print("\n3. 390px")
page.viewport(390, 844, mobile=True)
settle(0.6)
page.eval("currentStep = 9; updateStepUI(); window.scrollTo(0,0)")
settle(0.4)
b = bar(page)
chk("390px: the bar is present and its button reachable without scrolling",
b and b["btnVisible"], ascii_(b))
page.eval("window.scrollTo(0, document.documentElement.scrollHeight)")
settle(0.4)
clear = json.loads(page.eval("""JSON.stringify((() => {
const bar = document.querySelector('.step-navigation').getBoundingClientRect();
const fields = [...document.querySelectorAll(
'.step-content input, .step-content textarea, .step-content select, .step-content button')]
.filter(el => el.offsetParent !== null);
const lastEl = fields[fields.length - 1];
const r = lastEl ? lastEl.getBoundingClientRect() : null;
return {barTop: bar.top, lastBottom: r ? r.bottom : null};
})())"""))
chk("390px: fully scrolled, the last control sits ABOVE the bar - nothing is obscured",
clear["lastBottom"] is not None and clear["lastBottom"] <= clear["barTop"] + 1,
ascii_(clear))
js_errors = [e for e in page.js_errors()]
chk("no JavaScript errors anywhere in this run", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())