CLAUDE.md lists CR-005 among the change requests that get "silently half-built if
you treat them as frontend-only". This is the server half and the wizard half
together: a new table, four routes, an Alembic revision, and step 11.
CODES, NOT DISPLAY STRINGS, because CR-018 rolls cost up by these values and a
rollup keyed on a label breaks the day somebody fixes a typo in it. Two columns
carry that: `code` is a node's own slug, derived once at import and never
recomputed; `path` is the full slug path, unique per project, and is what a work
package will store. Renaming a value changes `name` alone - the probe renames a
floor and demands its path comes back byte-identical, with its children's paths
intact.
DEACTIVATE, NEVER DELETE. There is no DELETE route, and the probe checks for its
absence (405) rather than trusting that nobody added one. Deactivating hides a
value from new work packages and cascades DOWN, because a floor nobody can pick
must not keep offering its sectors. Reactivating walks UP only - a sector may
have been switched off for its own reasons, and silently resurrecting it would
undo a decision nobody made twice. That asymmetry is deliberate and is pinned by
a named check so it does not get "fixed" into a surprise.
Import reports rather than merges. Rejected rows come back with the SOURCE line
number and a reason; duplicates are listed as duplicates, separated into "already
in this project" and "already on line N of this import". Reusing a parent is not
a duplicate - B1/L2/1P and B1/L2/2P share a building and a floor by design, and
only the full path repeating counts. Re-importing a deactivated value brings the
same row back rather than creating a second one; the probe checks the id.
One parser, on the server. A CSV is read in the browser and posted as text
exactly as a paste is, so "what does a blank column mean" has one answer.
Comma, semicolon and tab all work - a paste out of a spreadsheet is tab
separated and a saved CSV is not, and which one somebody has is a question the
machine can answer.
No guessed floor names. IMPLEMENTATION.md section 8 says the B100 list has not
been supplied. The seeded sample has "Sample" inside every string, and the probe
greps html/ and server/ for a location-shaped assignment containing any of the
review's real names.
server/models.py LocationNode
server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
server/app.py GET/POST/PATCH + import, parser, slug
html/work-package-suite.html step 11, an 11th rail button
html/work-package-suite-app.js the step's logic; LAST_STEP replaces 10
html/work-package-suite-styles.css the list, the report
html/theme-light.css .field-error, now declared once
tests/locations_check.py new - 58 checks
tests/stepper_check.py STEP_COUNT 10 -> 11
Done when
[x] CSV upload and paste both work and report rejected rows with reasons
[x] duplicates are detected and reported rather than silently merged
[x] values are editable after import - rename, add, deactivate
[x] deactivating hides it from new work packages; an existing package
referencing it still resolves, because the row is retained
[x] values are stored as codes suitable for grouping
[x] no guessed real-world floor names exist anywhere in the code
Two decisions worth disagreeing with
Step 11, appended, not step 2, inserted. Locations belong beside Project by
subject. Renumbering 2-10 would touch every sop-step-N id, every
collectStepData case, every gate key and the analytics history - a large
silent-mismatch surface for an ordering change. The count now lives in one
place (LAST_STEP), so reordering later is cheap.
Any project member may edit the list, not only a Project Admin. It matches how
the SOP baseline itself is authored: the Project Admin gate is on CHANGING a
completed SOP, not on writing one. If the location list should be tighter than
the SOP it belongs to, that is a product call.
Verified one at a time
locations_check 58/58 new
stepper_check 70/70 (11 steps)
browser_check 71/71
a11y 22/22 sop now rings 38 focusable elements
url_state 23/23
autosave 34/34
aggregates 16/16
pipeline 43/43
launcher 58/58
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
alembic upgrade / downgrade / upgrade all clean on a throwaway SQLite
file, and the migrated schema matches Base.metadata.create_all
column for column - dev auto-creates and production migrates,
so a divergence between the two is invisible until it ships
.field-error was declared in two page sheets by the end of T5.2 and would have
been three by T5.8, so it moved to theme-light.css. No colour literal added
anywhere: still 0 across all page sheets and inline blocks.
Question for the PR, per CLAUDE.md: the levels are fixed at building / floor /
sector. Micron's floors behave like buildings, which this handles by letting a
project use whichever levels it needs - but a job that wants a fourth level, or
different names for the three, cannot say so. Whether that is worth a
per-project level vocabulary is a product question; the schema would take it
without a migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
542 lines
26 KiB
Python
542 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""The SOP wizard's step rail — A4 / S9 / C1 (T5.1).
|
|
|
|
The rail was ten div elements carrying onclick inside a horizontal scroller: not
|
|
in the tab order, unreachable by keyboard, and silent about progress. The one
|
|
thing on the page that did say where you were was a "1 / 10" pill in the app bar,
|
|
detached from the control it described.
|
|
|
|
Every one of T5.1's done-whens is checked here, because nothing that already
|
|
existed could check any of them:
|
|
|
|
1. every step is a <button> element (ten until T5.4 appended Locations)
|
|
2. the rail is operable by tab, arrow keys, Home/End, Enter and Space
|
|
3. the current step is exposed with aria-current
|
|
4. complete / current / unavailable are distinguishable WITHOUT colour
|
|
5. the "1 / 10" counter is gone
|
|
6. the app-wide div-with-onclick count dropped by 10 against the wave 0 baseline
|
|
|
|
and two things that are not on the list but would make the rail a liar:
|
|
|
|
7. the rail and validateStep() agree about which steps are open
|
|
8. no path through the rail reaches a native dialog
|
|
|
|
Note (7): the reachability rule is deliberately the guard's own rule — you may
|
|
leave the step you are on once its required fields are filled — and NOT "lock
|
|
everything after the first unmet gate anywhere". The guard has never enforced the
|
|
second, so a rail that drew it would show a padlock the Next button walks past.
|
|
|
|
Trap: window.alert and window.confirm are stubbed before anything is driven. A
|
|
native dialog does not fail a headless CDP session, it HANGS it, which reads as
|
|
"the browser would not start" three minutes later.
|
|
|
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
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, _c # noqa: E402
|
|
|
|
HTML_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "html")
|
|
|
|
# Wave 0 recorded 12. T5.1 must take ten of them.
|
|
BASELINE_DIV_ONCLICK = 12
|
|
|
|
# Ten until T5.4 (CR-005) appended Locations. Named rather than repeated, so the
|
|
# next step to be added moves one number instead of eight assertions.
|
|
STEP_COUNT = 11
|
|
|
|
# Swallow the wizard's remaining native dialogs and record that they fired. The
|
|
# wizard still has them until T5.8; this proves the RAIL never reaches one.
|
|
STUB = """
|
|
window.__dialogs = [];
|
|
window.alert = function (m) { window.__dialogs.push(['alert', String(m)]); };
|
|
window.confirm = function (m) { window.__dialogs.push(['confirm', String(m)]); return false; };
|
|
window.prompt = function (m) { window.__dialogs.push(['prompt', String(m)]); return null; };
|
|
true
|
|
"""
|
|
|
|
# One row per step, as the browser sees it.
|
|
RAIL_JS = r"""
|
|
JSON.stringify([...document.querySelectorAll('#step-rail-list .step-btn')].map(b => {
|
|
const cs = getComputedStyle(b);
|
|
const mk = b.querySelector('.step-btn-marker');
|
|
const mcs = mk ? getComputedStyle(mk) : null;
|
|
return {
|
|
step: +b.dataset.step,
|
|
tag: b.tagName,
|
|
type: b.getAttribute('type'),
|
|
tabindex: b.tabIndex,
|
|
disabled: !!b.disabled,
|
|
ariaDisabled: b.getAttribute('aria-disabled'),
|
|
ariaCurrent: b.getAttribute('aria-current'),
|
|
label: (b.querySelector('.step-btn-label') || {}).textContent || '',
|
|
state: ((b.querySelector('.step-btn-state') || {}).textContent || '').trim(),
|
|
marker: mk ? (mk.textContent || '').trim() : '',
|
|
markerBorderStyle: mcs ? mcs.borderTopStyle : '',
|
|
markerBg: mcs ? mcs.backgroundColor : '',
|
|
color: cs.color,
|
|
bg: cs.backgroundColor,
|
|
visible: !!(b.getBoundingClientRect().width && b.getBoundingClientRect().height),
|
|
};
|
|
}))
|
|
"""
|
|
|
|
|
|
# Real key events, not a synthetic KeyboardEvent. Page.key() dispatches on
|
|
# `document`, which never reaches a listener bound to the rail and never triggers
|
|
# a button's native Enter/Space activation — it would report a clean pass on a
|
|
# rail with no keyboard support at all.
|
|
_KEYS = {
|
|
"ArrowDown": dict(key="ArrowDown", code="ArrowDown", vk=40),
|
|
"ArrowUp": dict(key="ArrowUp", code="ArrowUp", vk=38),
|
|
"ArrowLeft": dict(key="ArrowLeft", code="ArrowLeft", vk=37),
|
|
"ArrowRight": dict(key="ArrowRight", code="ArrowRight", vk=39),
|
|
"Home": dict(key="Home", code="Home", vk=36),
|
|
"End": dict(key="End", code="End", vk=35),
|
|
"Enter": dict(key="Enter", code="Enter", vk=13, text="\r"),
|
|
"Space": dict(key=" ", code="Space", vk=32, text=" "),
|
|
}
|
|
|
|
|
|
def press(page, name, settle_s=0.35):
|
|
spec = _KEYS[name]
|
|
common = {"key": spec["key"], "code": spec["code"],
|
|
"windowsVirtualKeyCode": spec["vk"], "nativeVirtualKeyCode": spec["vk"]}
|
|
down = dict(common, type="keyDown" if "text" in spec else "rawKeyDown")
|
|
if "text" in spec:
|
|
down["text"] = spec["text"]
|
|
down["unmodifiedText"] = spec["text"]
|
|
page.ws.call("Input.dispatchKeyEvent", down)
|
|
page.ws.call("Input.dispatchKeyEvent", dict(common, type="keyUp"))
|
|
time.sleep(settle_s)
|
|
page.ws.drain(0.2)
|
|
|
|
|
|
def dismiss_dialogs(page):
|
|
"""Answer any native dialog that opened during a navigation.
|
|
|
|
A real key event gives the page sticky user activation, which is what lets
|
|
WPAutosave's beforeunload guard raise a browser-level "Leave site?" prompt on
|
|
the NEXT navigation. That prompt is not JavaScript, so the alert() stub cannot
|
|
catch it, and an unanswered one stalls the CDP session rather than failing it.
|
|
"""
|
|
if not any(e.get("method") == "Page.javascriptDialogOpening" for e in page.ws.events):
|
|
return
|
|
for e in list(page.ws.events):
|
|
if e.get("method") == "Page.javascriptDialogOpening":
|
|
try:
|
|
page.ws.call("Page.handleJavaScriptDialog", {"accept": True})
|
|
except Exception:
|
|
pass
|
|
page.ws.drain(0.3)
|
|
|
|
|
|
def rail(page):
|
|
return json.loads(page.eval(RAIL_JS))
|
|
|
|
|
|
def settle(page, seconds=1.4):
|
|
time.sleep(seconds)
|
|
|
|
|
|
def fill_step1(page):
|
|
page.eval("""(() => {
|
|
for (const [id, v] of [['proj_name','Probe project'], ['proj_number','P-1'],
|
|
['proj_client','Probe client'], ['proj_division','Probe division'],
|
|
['proj_site','Probe site']]) {
|
|
const el = document.getElementById(id);
|
|
el.value = v;
|
|
el.dispatchEvent(new Event('input', {bubbles: true}));
|
|
}
|
|
return true;
|
|
})()""")
|
|
|
|
|
|
def clear_step1(page):
|
|
page.eval("""(() => {
|
|
const el = document.getElementById('proj_site');
|
|
el.value = '';
|
|
el.dispatchEvent(new Event('input', {bubbles: true}));
|
|
return true;
|
|
})()""")
|
|
|
|
|
|
def source_counts():
|
|
"""The wave 0 baseline metrics, measured the way file-map.md defines them."""
|
|
div_onclick = span_onclick = 0
|
|
for name in sorted(os.listdir(HTML_DIR)):
|
|
if not (name.endswith(".html") or name.endswith(".js")):
|
|
continue
|
|
with open(os.path.join(HTML_DIR, name), encoding="utf-8") as fh:
|
|
src = fh.read()
|
|
div_onclick += len(re.findall(r"<div[^>]*onclick", src))
|
|
span_onclick += len(re.findall(r"<span[^>]*onclick", src))
|
|
return div_onclick, span_onclick
|
|
|
|
|
|
def run(page, base, tok):
|
|
def open_wizard(query="?project=projA"):
|
|
# Leave the outgoing page clean first: the unsaved-work guard from T4.3 is
|
|
# doing its job, and a "Leave site?" prompt would stall the navigation.
|
|
try:
|
|
page.eval("typeof sopMarkSaved === 'function' && sopMarkSaved()")
|
|
except Exception:
|
|
pass
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.goto(base + "/work-package-suite.html" + query)
|
|
dismiss_dialogs(page)
|
|
settle(page, 1.8)
|
|
page.eval(STUB)
|
|
|
|
# ── 1. every step is a real button ────────────────────────────────────────
|
|
print("\n1. every step is a <button> element")
|
|
open_wizard()
|
|
rows = rail(page)
|
|
chk("the rail renders %d steps" % STEP_COUNT, len(rows) == STEP_COUNT,
|
|
"found %d" % len(rows))
|
|
chk("...every one of them a <button>", rows and all(r["tag"] == "BUTTON" for r in rows),
|
|
[r["tag"] for r in rows])
|
|
chk('...with type="button", so none of them submits anything',
|
|
rows and all(r["type"] == "button" for r in rows), [r["type"] for r in rows])
|
|
chk("...and none is a div or a span with a click handler",
|
|
page.eval("!document.querySelector('#step-rail [onclick]')"))
|
|
chk("the rail is a landmark with a name",
|
|
page.eval("(document.getElementById('step-rail')||{}).tagName") == "NAV"
|
|
and bool(page.eval("(document.getElementById('step-rail')||{}).getAttribute('aria-label')")))
|
|
chk("...and an ordered list, so position carries order for free",
|
|
page.eval("!!document.querySelector('#step-rail ol#step-rail-list')"))
|
|
|
|
# ── 5. the counter is gone ────────────────────────────────────────────────
|
|
print("\n5. the orphaned 1 / 10 counter is gone")
|
|
chk("no #current-step element", page.eval("!document.getElementById('current-step')"))
|
|
chk("no #total-steps element", page.eval("!document.getElementById('total-steps')"))
|
|
chk("no .step-counter anywhere on the page",
|
|
page.eval("!document.querySelector('.step-counter')"))
|
|
chk("...and no bare 'N / %d' left in the app bar" % STEP_COUNT,
|
|
not re.search(r"\b\d+\s*/\s*%d\b" % STEP_COUNT,
|
|
page.eval("(document.querySelector('.header')||{}).textContent||''")),
|
|
page.eval("(document.querySelector('.header')||{}).textContent||''")[:120])
|
|
|
|
# ── 6. the div-onclick baseline moved ─────────────────────────────────────
|
|
print("\n6. the wave 0 <div onclick> count dropped by 10")
|
|
divs, spans = source_counts()
|
|
chk("app-wide div-with-onclick is %d, down 10 from %d"
|
|
% (divs, BASELINE_DIV_ONCLICK), divs == BASELINE_DIV_ONCLICK - 10,
|
|
"counted %d" % divs)
|
|
chk("...and none of the survivors is in the wizard's rail",
|
|
page.eval("document.querySelectorAll('#step-rail div').length") == 0)
|
|
print(" span-with-onclick unchanged at %d (wave 9 owns those)" % spans)
|
|
|
|
# ── 3 + 4. states, told apart without colour ──────────────────────────────
|
|
print("\n3 + 4. current, complete and unavailable, without relying on colour")
|
|
rows = rail(page)
|
|
cur = [r for r in rows if r["ariaCurrent"] == "step"]
|
|
chk("exactly one step carries aria-current=step", len(cur) == 1,
|
|
[r["step"] for r in cur])
|
|
chk("...and it is the step being shown", cur and cur[0]["step"] == 1, cur)
|
|
chk("...which also says so in words", cur and cur[0]["state"] == "Current step", cur)
|
|
|
|
# projA's fixture project has no division or site, so step 1 is incomplete on
|
|
# a fresh load and everything ahead of it is genuinely out of reach.
|
|
locked = [r for r in rows if r["ariaDisabled"] == "true"]
|
|
chk("with step 1 incomplete, steps 2-10 are unavailable",
|
|
[r["step"] for r in locked] == list(range(2, STEP_COUNT + 1)),
|
|
[r["step"] for r in locked])
|
|
chk("...each saying 'Locked' in words, not only in colour",
|
|
locked and all(r["state"] == "Locked" for r in locked),
|
|
list({r["state"] for r in locked}))
|
|
chk("...and drawn with a different marker SHAPE (dashed, not solid)",
|
|
locked and all(r["markerBorderStyle"] == "dashed" for r in locked),
|
|
list({r["markerBorderStyle"] for r in locked}))
|
|
chk("...while remaining focusable, so a keyboard user can be told why",
|
|
locked and all(r["tabindex"] >= 0 and not r["disabled"] for r in locked))
|
|
chk("...and carrying the reason on hover and on focus",
|
|
page.eval("!!document.querySelector('#step-rail-list .step-btn.is-locked[title]')"))
|
|
|
|
fill_step1(page)
|
|
page.eval("goToStep(3)")
|
|
settle(page, 0.8)
|
|
rows = rail(page)
|
|
by = {r["step"]: r for r in rows}
|
|
chk("once step 1 is filled and left behind, it reads Complete",
|
|
by[1]["state"] == "Complete", by[1])
|
|
chk("...with a tick rather than its number — a glyph, not a hue",
|
|
by[1]["marker"] == "✓", repr(by[1]["marker"]))
|
|
chk("...and step 3 is now the current one", by[3]["ariaCurrent"] == "step", by[3])
|
|
chk("nothing is locked while the current step has no required fields",
|
|
not [r for r in rows if r["ariaDisabled"] == "true"],
|
|
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
|
|
chk("a step never opened is neither complete nor locked",
|
|
by[9]["state"] == "" and by[9]["ariaDisabled"] is None, by[9])
|
|
# The three states must be separable with colour discarded entirely.
|
|
words = {by[1]["state"], by[3]["state"], "Locked"}
|
|
chk("complete / current / unavailable are three distinct words",
|
|
len(words) == 3, sorted(words))
|
|
|
|
# ── 2. keyboard ───────────────────────────────────────────────────────────
|
|
print("\n2. the rail is operable from the keyboard alone")
|
|
chk("every step is in the tab order",
|
|
all(r["tabindex"] >= 0 for r in rows), [r["tabindex"] for r in rows])
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"1\"]').focus()")
|
|
chk("focus lands on a step button",
|
|
page.eval("(document.activeElement.dataset||{}).step") == "1")
|
|
scroll_before = page.eval("window.scrollY")
|
|
press(page, "ArrowDown")
|
|
chk("ArrowDown moves to the next step",
|
|
page.eval("(document.activeElement.dataset||{}).step") == "2",
|
|
page.eval("(document.activeElement.dataset||{}).step"))
|
|
press(page, "ArrowUp")
|
|
chk("ArrowUp moves back",
|
|
page.eval("(document.activeElement.dataset||{}).step") == "1",
|
|
page.eval("(document.activeElement.dataset||{}).step"))
|
|
press(page, "ArrowRight")
|
|
chk("ArrowRight works too, for a rail that is a row at some widths",
|
|
page.eval("(document.activeElement.dataset||{}).step") == "2",
|
|
page.eval("(document.activeElement.dataset||{}).step"))
|
|
press(page, "End")
|
|
chk("End jumps to the last step",
|
|
page.eval("(document.activeElement.dataset||{}).step") == str(STEP_COUNT),
|
|
page.eval("(document.activeElement.dataset||{}).step"))
|
|
press(page, "Home")
|
|
chk("Home jumps to the first",
|
|
page.eval("(document.activeElement.dataset||{}).step") == "1",
|
|
page.eval("(document.activeElement.dataset||{}).step"))
|
|
chk("...and the arrow keys do not scroll the page out from under it",
|
|
page.eval("window.scrollY") == scroll_before,
|
|
[scroll_before, page.eval("window.scrollY")])
|
|
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"5\"]').focus()")
|
|
press(page, "Enter")
|
|
settle(page, 0.7)
|
|
chk("Enter activates the focused step", page.eval("currentStep") == 5,
|
|
page.eval("currentStep"))
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"2\"]').focus()")
|
|
press(page, "Space")
|
|
settle(page, 0.7)
|
|
chk("Space activates it too", page.eval("currentStep") == 2, page.eval("currentStep"))
|
|
chk("...and the step content actually changed, not just the variable",
|
|
page.eval("getComputedStyle(document.getElementById('sop-step-2')).display") == "block")
|
|
|
|
# ── 7. the rail and the guard agree ───────────────────────────────────────
|
|
print("\n7. the rail and validateStep() agree about what is open")
|
|
page.eval("goToStep(1)")
|
|
settle(page, 0.6)
|
|
clear_step1(page)
|
|
# No renderStepRail() here on purpose: the rail must track the form as it is
|
|
# typed, not only when you navigate. A rail that says "locked" over a form you
|
|
# have just filled in is worse than the strip it replaced.
|
|
rows = rail(page)
|
|
chk("emptying a required field re-locks the steps ahead, with no navigation",
|
|
[r["step"] for r in rows if r["ariaDisabled"] == "true"] == list(range(2, STEP_COUNT + 1)),
|
|
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
|
|
chk("...and the guard refuses the same move",
|
|
page.eval("(() => { const n = currentStep; goToStep(4); return currentStep === n; })()"))
|
|
# That call went through validateStep() directly rather than through the rail,
|
|
# so it hit the wizard's surviving alert. Recorded here so the fact is on the
|
|
# page rather than hidden, and cleared so check 8 below still means something.
|
|
guard_dialogs = json.loads(page.eval("JSON.stringify(window.__dialogs||[])"))
|
|
chk("...via the wizard's alert, which T5.8 still owns",
|
|
len(guard_dialogs) == 1 and guard_dialogs[0][0] == "alert", guard_dialogs)
|
|
page.eval("window.__dialogs = []")
|
|
chk("...while going BACKWARDS is still allowed, so nobody is trapped",
|
|
page.eval("(() => { currentStep = 4; updateStepUI(); goToStep(2); return currentStep; })()") == 2,
|
|
page.eval("currentStep"))
|
|
|
|
page.eval("goToStep(1)")
|
|
settle(page, 0.6)
|
|
fill_step1(page)
|
|
rows = rail(page)
|
|
chk("filling it unlocks them again, as it is typed",
|
|
not [r for r in rows if r["ariaDisabled"] == "true"],
|
|
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
|
|
|
|
# ── the blocked click is useful, not just refused ─────────────────────────
|
|
print("\n a refused step says why and puts the cursor where the work is")
|
|
clear_step1(page)
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"7\"]').click()")
|
|
settle(page, 0.6)
|
|
msg = page.eval("(document.getElementById('step-rail-msg')||{}).textContent||''")
|
|
chk("the refusal is announced, not silent", bool(msg.strip()), repr(msg))
|
|
chk("...and names both the step refused and the step in the way",
|
|
"Platforms" in msg and "Project" in msg, repr(msg))
|
|
chk("...through a live region, so a screen reader hears it",
|
|
page.eval("(document.getElementById('step-rail-msg')||{}).getAttribute('role')") == "alert")
|
|
chk("...and focus moved to the empty required field",
|
|
page.eval("(document.activeElement||{}).id") == "proj_site",
|
|
page.eval("(document.activeElement||{}).id"))
|
|
chk("...without changing which step is open", page.eval("currentStep") == 1,
|
|
page.eval("currentStep"))
|
|
fill_step1(page)
|
|
chk("...and the refusal clears itself once the field is filled",
|
|
not (page.eval("(document.getElementById('step-rail-msg')||{}).textContent||''")
|
|
.strip()),
|
|
page.eval("(document.getElementById('step-rail-msg')||{}).textContent||''"))
|
|
|
|
# ── 8. no native dialogs on any rail path ─────────────────────────────────
|
|
print("\n8. no rail interaction reaches a native dialog")
|
|
fired = json.loads(page.eval("JSON.stringify(window.__dialogs||[])"))
|
|
chk("nothing opened alert / confirm / prompt during the whole rail exercise",
|
|
not fired, fired[:3])
|
|
|
|
# ── the URL still tracks the step (S3 / T4.2 must not regress) ────────────
|
|
print("\n the rail still records where you are in the URL (S3)")
|
|
fill_step1(page)
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"2\"]').click()")
|
|
settle(page, 0.8)
|
|
hlen = page.eval("history.length")
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"6\"]').click()")
|
|
settle(page, 0.8)
|
|
chk("clicking a step puts it in the address bar",
|
|
"step=6" in page.eval("location.search"), page.eval("location.search"))
|
|
chk("...as a history entry", page.eval("history.length") > hlen)
|
|
page.eval("history.back()")
|
|
settle(page, 1.1)
|
|
chk("...and Back returns to the previous step", page.eval("currentStep") == 2,
|
|
page.eval("currentStep"))
|
|
# Back to a URL with NO step at all does not return to step 1 — the popstate
|
|
# handler parses `step` and ignores a NaN. That is T4.2's restore rather than
|
|
# the rail's, it predates this task, and it is logged as BL-016.
|
|
chk("...and the known step-1 gap is still exactly that, and no wider",
|
|
page.eval("(() => { history.back(); return true; })()") is True)
|
|
settle(page, 1.1)
|
|
chk("...(BL-016) Back to a step-less URL leaves the step where it was",
|
|
page.eval("currentStep") == 2 and "step=" not in page.eval("location.search"),
|
|
[page.eval("currentStep"), page.eval("location.search")])
|
|
|
|
# ── narrow width: the gloved-hands surface ────────────────────────────────
|
|
print("\n at 390px the rail collapses to a disclosure and still works")
|
|
page.viewport(390, 780, mobile=True)
|
|
open_wizard()
|
|
page.viewport(390, 780, mobile=True)
|
|
settle(page, 1.0)
|
|
chk("the disclosure button is the visible control",
|
|
page.eval("getComputedStyle(document.getElementById('step-rail-toggle')).display") != "none")
|
|
chk("...starting collapsed", page.eval(
|
|
"getComputedStyle(document.getElementById('step-rail-list')).display") == "none")
|
|
chk("...and saying so", page.eval(
|
|
"document.getElementById('step-rail-toggle').getAttribute('aria-expanded')") == "false")
|
|
chk("...naming the step you are on, which is what the counter used to do",
|
|
"Project" in (page.eval(
|
|
"(document.getElementById('step-rail-toggle')||{}).textContent||''")),
|
|
page.eval("(document.getElementById('step-rail-toggle')||{}).textContent||''"))
|
|
page.click("#step-rail-toggle")
|
|
settle(page, 0.5)
|
|
chk("tapping it reveals every step",
|
|
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
|
".filter(b => b.getBoundingClientRect().height > 0).length") == STEP_COUNT,
|
|
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
|
".filter(b => b.getBoundingClientRect().height > 0).length"))
|
|
chk("...and flips aria-expanded", page.eval(
|
|
"document.getElementById('step-rail-toggle').getAttribute('aria-expanded')") == "true")
|
|
chk("every step is at least a 44px tap target",
|
|
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
|
".every(b => b.getBoundingClientRect().height >= 44)"),
|
|
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
|
".map(b => Math.round(b.getBoundingClientRect().height))"))
|
|
chk("the page does not scroll sideways at 390px",
|
|
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
|
|
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
|
|
fill_step1(page)
|
|
page.eval("document.querySelector('#step-rail-list .step-btn[data-step=\"4\"]').click()")
|
|
settle(page, 0.8)
|
|
chk("choosing a step navigates", page.eval("currentStep") == 4, page.eval("currentStep"))
|
|
chk("...and closes the disclosure behind you", page.eval(
|
|
"document.getElementById('step-rail').classList.contains('is-collapsed')"))
|
|
|
|
print("\n and the wide layout puts it beside the form, not above it")
|
|
page.viewport(1440, 900)
|
|
open_wizard()
|
|
page.viewport(1440, 900)
|
|
settle(page, 1.2)
|
|
geo = json.loads(page.eval("""JSON.stringify((() => {
|
|
const r = document.getElementById('step-rail').getBoundingClientRect();
|
|
const c = document.querySelector('.step-content').getBoundingClientRect();
|
|
return {railRight: Math.round(r.right), contentLeft: Math.round(c.left),
|
|
railTop: Math.round(r.top), contentTop: Math.round(c.top),
|
|
sticky: getComputedStyle(document.getElementById('step-rail')).position};
|
|
})())"""))
|
|
chk("the rail sits to the LEFT of the form at desk width",
|
|
geo["railRight"] <= geo["contentLeft"] + 1, geo)
|
|
chk("...on the same row, not stacked above it",
|
|
abs(geo["railTop"] - geo["contentTop"]) < 40, geo)
|
|
chk("...and stays put while a three-screen step scrolls",
|
|
geo["sticky"] == "sticky", geo)
|
|
chk("the disclosure is out of the way at this width",
|
|
page.eval("getComputedStyle(document.getElementById('step-rail-toggle')).display")
|
|
== "none")
|
|
chk("the page does not scroll sideways at 1440px",
|
|
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
|
|
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
|
|
chk("the wizard still boots without a JavaScript error", not page.js_errors(),
|
|
page.js_errors())
|
|
|
|
|
|
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-stepper-")
|
|
db_path = os.path.join(tmpdir, "check.db")
|
|
server = None
|
|
try:
|
|
tok = seed(db_path)
|
|
port = cdp.free_port()
|
|
base = "http://127.0.0.1:%d" % port
|
|
server = start_server(port, db_path)
|
|
if server is None:
|
|
print("the test server would not start.")
|
|
return 2
|
|
print("\nSOP step rail — A4 / S9 / C1\nTarget: %s" % base)
|
|
|
|
browser = cdp.Browser(exe)
|
|
page = browser.page()
|
|
try:
|
|
run(page, base, tok)
|
|
finally:
|
|
page.close()
|
|
browser.close()
|
|
finally:
|
|
if server:
|
|
server.kill()
|
|
try:
|
|
server.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
try:
|
|
from server.db import engine
|
|
engine.dispose()
|
|
except Exception:
|
|
pass
|
|
import shutil
|
|
for _ in range(10):
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
if not os.path.exists(tmpdir):
|
|
break
|
|
time.sleep(0.3)
|
|
|
|
total = len(_PASS) + len(_FAIL)
|
|
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
|
|
if _FAIL:
|
|
for f in _FAIL:
|
|
print(" - " + f)
|
|
return 1
|
|
print("\nResult: " + _c("ALL PASS — ten real buttons, keyboard operable, states in words.", "32") + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|