Files
Project-SDE-WP-Suite/tests/validation_check.py
n.siegfried b44afa7672 T9.4 - S7: one sample-data affordance, confirmed, and fenced off the project
Four affordances under three names became ONE: "Load sample data", on the
creator's toolbar, at the far end of two separators from the live actions
(New / Duplicate), pushed right with its own gap. It confirms through the
T7.9 dialog, naming exactly what it does - and what it does not: "This page
only: nothing is written to the project unless you then save." The probe
verifies the fence the way the done-when demands - against a REAL project,
reading the server's SOP and work-package list before and after and asserting
byte-identical.

Gone: the wizard's header "Load sample" (the dangerous one: it filled the
state completeSOP() pushes to the LIVE project, one click, no confirm, no
undo - reconciled with D1 exactly as the task records: the creator's control
is the survivor, the wizard copy goes), the creator's split Sample SOP /
Load example pair (now internals behind the one entry point), and the
empty-state context bar's third button (its text now points at the toolbar
control). The location/material "Load sample values" buttons stay: they fill
a PASTE BOX that acts only through an explicit, dry-runnable import - a
different thing, stated in the code.

Probes re-pointed with reasons in place: frame_check's D1 toolbar list names
the consolidated control; validation_check's sample-driven toast checks
became the-affordance-is-gone checks (and its stale showAnalytics drive,
orphaned by T7.10, became a the-duplicate-stays-gone check).

Verification (each probe run alone): NEW tests/sample_check.py 10/10.
Regressions: validation_check 77/77, frame_check 38/38, kitting_check 26/26,
export_check 20/20, sections_check 95/95.

Items: S7 (D1 reconciliation honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:05:56 -07:00

400 lines
17 KiB
Python

#!/usr/bin/env python3
"""Inline validation in the SOP wizard — S1, wizard half (T5.8).
The defect S1 records has two halves and they are easy to conflate. One is that
validation was a native dialog. The other is that it guarded three steps while
the markup marked required fields on five — so two steps' asterisks meant
nothing at all, which is worse than no asterisk.
1. every step with required fields validates them (1, 3, 5, 6, 7 — counted
from the markup, not from the old guard)
2. each error renders at its field and is associated via aria-describedby
3. submitting an invalid step focuses and scrolls to the first error
4. errors announce to screen readers
5. the wizard's native dialog count is 0
Check 5 is measured two ways, because the wave 0 metric counts the word
`alert(` inside a comment as readily as inside code (BL-017). Both figures are
recorded: raw, and with comments stripped.
Every dialog is stubbed before anything is driven, so a survivor is caught and
reported rather than hanging the session.
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
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
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
"""
# Read out of the markup, not out of the app's gate table: the S1 defect is
# precisely that the two disagreed, so a probe that read the table would agree
# with whatever the table says and prove nothing.
STEP_FIELDS = {
1: ["proj_name", "proj_number", "proj_client", "proj_division", "proj_site"],
3: ["role_super_title", "role_foreman_title"],
5: ["gov_woformat", "gov_discmode"],
6: ["qual_qcreq"],
7: ["plat_tracking", "plat_commissioning"],
}
NO_REQUIRED = [2, 4, 8, 9, 10, 11, 12]
def settle(seconds=1.4):
time.sleep(seconds)
def ascii_(v):
"""Windows consoles are cp1252, and a failure message carrying the toast's
close glyph crashes the reporter instead of reporting the failure. Losing the
glyph costs nothing; losing the diagnosis costs the run."""
return str(v).encode("ascii", "replace").decode("ascii")
def open_wizard(page, base, tok, query="?project=projA"):
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.goto(base + "/work-package-suite.html" + query)
settle(2.0)
page.eval(STUB)
def required_fields_in_markup():
"""Which steps mark a field required, straight from work-package-suite.html.
Two notations: an asterisk in the <label> text, and a red <span>*</span> beside
an input (step 3's role titles). Both count — the markup was making a promise
either way."""
src = open(os.path.join(ROOT, "html", "work-package-suite.html"), encoding="utf-8").read()
bounds = [(m.start(), int(m.group(1))) for m in re.finditer(r'id="sop-step-(\d+)"', src)]
bounds.append((len(src), None))
marked = {}
for k in range(len(bounds) - 1):
start, n = bounds[k]
block = src[start:bounds[k + 1][0]]
starred = len(re.findall(r"<label[^>]*>[^<]*\*", block))
starred += len(re.findall(r'var\(--danger\)[^>]*>\*<', block))
if starred:
marked[n] = starred
return marked
def field_state(page, ids):
return json.loads(page.eval("""JSON.stringify(%s.map(id => {
const el = document.getElementById(id);
const box = document.getElementById(id + '_err');
return {
id: id,
exists: !!el,
invalid: el ? el.getAttribute('aria-invalid') : null,
describedby: el ? (el.getAttribute('aria-describedby') || '') : '',
error: box ? (box.textContent || '').trim() : null,
role: box ? box.getAttribute('role') : null,
};
}))""" % json.dumps(ids)))
def blank(page, ids):
page.eval("""(() => {
for (const id of %s) {
const el = document.getElementById(id);
if (!el) continue;
if (el.tagName === 'SELECT') {
if (![...el.options].some(o => o.value === '')) el.add(new Option('', ''), 0);
el.value = '';
} else { el.value = ''; }
el.dispatchEvent(new Event('input', {bubbles: true}));
el.dispatchEvent(new Event('change', {bubbles: true}));
}
return true;
})()""" % json.dumps(ids))
def run(page, base, tok):
print("\n1. every step the MARKUP marks required is validated")
marked = required_fields_in_markup()
chk("the markup marks required fields on 5 steps",
sorted(marked) == sorted(STEP_FIELDS), sorted(marked))
open_wizard(page, base, tok)
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
gated = json.loads(page.eval(
"JSON.stringify(Object.keys(STEP_GATES).map(Number).sort((a,b)=>a-b))"))
chk("...and the guard now covers exactly those 5, not the old 3",
gated == sorted(STEP_FIELDS), gated)
for n in sorted(STEP_FIELDS):
got = json.loads(page.eval("JSON.stringify(stepGateFields(%d))" % n))
chk("step %-2d gates on %d field(s), the ones the markup marks" % (n, len(STEP_FIELDS[n])),
sorted(got) == sorted(STEP_FIELDS[n]), got)
for n in NO_REQUIRED:
chk("step %-2d has no required fields and gates on none" % n,
page.eval("JSON.stringify(stepGateFields(%d))" % n) == "[]",
page.eval("JSON.stringify(stepGateFields(%d))" % n))
print("\n2 + 3 + 4. an invalid step marks its fields, focuses the first, announces")
for n in sorted(STEP_FIELDS):
ids = STEP_FIELDS[n]
page.eval("goToStep(%d, {fromUrl:true}); updateStepUI()" % n)
settle(0.5)
blank(page, ids)
ok = page.eval("validateStep(%d)" % n)
settle(0.4)
st = field_state(page, ids)
chk("step %-2d refuses while its required fields are empty" % n, ok is False, ok)
chk(" ...an error at every one of them",
all(f["error"] for f in st), [f["id"] for f in st if not f["error"]])
chk(" ...each naming its own field, not one message for the step",
len({f["error"] for f in st}) == len(st), [f["error"] for f in st])
chk(" ...associated through aria-describedby",
all(f["id"] + "_err" in f["describedby"] for f in st),
[(f["id"], f["describedby"]) for f in st])
chk(" ...marked aria-invalid", all(f["invalid"] == "true" for f in st),
[(f["id"], f["invalid"]) for f in st])
chk(" ...announced through a live region",
all(f["role"] == "alert" for f in st), [(f["id"], f["role"]) for f in st])
chk(" ...and focus is on the FIRST empty one",
page.eval("(document.activeElement||{}).id") == ids[0],
page.eval("(document.activeElement||{}).id"))
print("\n an error clears as the field is corrected, not on the next submit")
page.eval("goToStep(1, {fromUrl:true}); updateStepUI()")
settle(0.4)
blank(page, STEP_FIELDS[1])
page.eval("validateStep(1)")
settle(0.3)
chk("the error is showing to begin with",
bool(field_state(page, ["proj_name"])[0]["error"]))
page.eval("""(() => {
const el = document.getElementById('proj_name');
el.value = 'Typed';
el.dispatchEvent(new Event('input', {bubbles: true}));
return true;
})()""")
settle(0.4)
after = field_state(page, ["proj_name", "proj_number"])
chk("...and clears the moment that field is filled", not after[0]["error"], after[0])
chk("...without clearing the ones still empty", bool(after[1]["error"]), after[1])
print("\n the first error is scrolled to, not only focused")
page.eval("goToStep(7, {fromUrl:true}); updateStepUI(); window.scrollTo(0, 0)")
settle(0.5)
blank(page, STEP_FIELDS[7])
page.eval("validateStep(7)")
settle(0.9)
chk("the focused field is inside the viewport", page.eval("""(() => {
const el = document.getElementById('plat_tracking');
const r = el.getBoundingClientRect();
return r.top >= -2 && r.bottom <= window.innerHeight + 2;
})()"""))
print("\n Next is refused, and the rail agrees with the refusal")
page.eval("goToStep(1, {fromUrl:true}); updateStepUI()")
settle(0.4)
blank(page, STEP_FIELDS[1])
page.eval("nextStep()")
settle(0.5)
chk("Next does not leave an invalid step", page.eval("currentStep") == 1,
page.eval("currentStep"))
chk("...and marks the fields, the same way validateStep does",
all(f["error"] for f in field_state(page, STEP_FIELDS[1])))
chk("...while the rail shows the steps ahead as unavailable", page.eval(
"[...document.querySelectorAll('#step-rail-list .step-btn[aria-disabled=true]')].length")
== 11,
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn[aria-disabled=true]')]"
".map(b => b.dataset.step)"))
print("\n merely LOOKING at a step does not paint it red")
open_wizard(page, base, tok)
for n in sorted(STEP_FIELDS):
page.eval("goToStep(%d, {fromUrl:true}); updateStepUI()" % n)
settle(0.3)
marks = json.loads(page.eval(
"JSON.stringify([...document.querySelectorAll('.field-error')]"
".filter(e => (e.textContent||'').trim()).map(e => e.id))"))
chk("no error is shown on a step nobody has tried to leave", not marks, marks)
print("\n5. the wizard opens no native dialog, and its sample affordance is gone")
open_wizard(page, base, tok)
# T9.4 (S7) removed this page's Load sample - it filled the state that
# completeSOP() pushes to the LIVE project, one click, no confirm. The
# suite's one sample control is on the creator. What this section still
# owns: the wizard must not regrow the affordance, and must stay dialog-free.
chk("the wizard's Load sample control is gone (S7/T9.4)",
page.eval("!document.getElementById('load-sample-btn')")
and page.eval("typeof loadSampleData === 'undefined'"))
chk("...and no native dialog fired anywhere so far",
not json.loads(page.eval("JSON.stringify(window.__dialogs||[])")),
page.eval("JSON.stringify(window.__dialogs||[])"))
# Through the control a person uses — the modal's text input — not through the
# library helper, which has no duplicate message to give.
page.eval("""(() => {
showConstraintLibrary();
const i = document.getElementById('custom-constraint-input');
i.value = 'Probe constraint'; addCustomConstraintText();
i.value = 'Probe constraint'; addCustomConstraintText();
return true;
})()""")
settle(0.6)
chk("adding a duplicate constraint says so without a dialog",
"already in the list" in (page.eval(
"(document.getElementById('wp-toast')||{}).textContent||''")),
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
page.eval("closeConstraintModal()")
page.eval("toggleComments(); document.getElementById('comment-text').value=''; submitComment()")
settle(0.6)
chk("an empty comment is refused at the field",
bool((page.eval("(document.getElementById('comment-text_err')||{}).textContent||''")).strip()),
ascii_(page.eval("(document.getElementById('comment-text_err')||{}).textContent||''")))
chk("...announced", page.eval(
"document.getElementById('comment-text_err').getAttribute('role')") == "alert")
chk("...and focused", page.eval("(document.activeElement||{}).id") == "comment-text")
page.eval("exportComments()")
settle(0.5)
chk("exporting nothing says so without a dialog",
"no feedback to export" in (page.eval(
"(document.getElementById('wp-toast')||{}).textContent||''")).lower(),
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
# showAnalytics() left this page at T7.10 (D5): the usage report lives on
# the admin console now, one implementation for the whole suite. What this
# section keeps: the duplicate must not regrow here.
chk("the wizard's usage-summary duplicate is gone (D5/T7.10)",
page.eval("typeof showAnalytics === 'undefined'")
and page.eval("typeof analyticsLoad === 'undefined'"))
fired = json.loads(page.eval("JSON.stringify(window.__dialogs||[])"))
chk("nothing anywhere in that opened alert / confirm / prompt", not fired, fired[:4])
print("\n and the source agrees — the count, measured both ways")
src = open(os.path.join(ROOT, "html", "work-package-suite-app.js"), encoding="utf-8").read()
raw = len(re.findall(r"\b(alert|confirm|prompt)\(", src))
code = "\n".join(ln for ln in src.splitlines() if not ln.strip().startswith("//"))
stripped = len(re.findall(r"\b(alert|confirm|prompt)\(", code))
chk("work-package-suite-app.js: 0 native dialogs, raw count", raw == 0, raw)
chk("...and 0 with comments stripped too", stripped == 0, stripped)
app_raw = app_stripped = 0
per_file = {}
for name in sorted(os.listdir(os.path.join(ROOT, "html"))):
if not name.endswith((".js", ".html")):
continue
text = open(os.path.join(ROOT, "html", name), encoding="utf-8").read()
r = len(re.findall(r"\b(alert|confirm|prompt)\(", text))
c = len(re.findall(r"\b(alert|confirm|prompt)\(",
"\n".join(ln for ln in text.splitlines()
if not ln.strip().startswith("//"))))
app_raw += r
app_stripped += c
if r:
per_file[name] = (r, c)
print(" app-wide: %d raw, %d with comments stripped (wave 0 recorded 79)"
% (app_raw, app_stripped))
for name, (r, c) in sorted(per_file.items()):
print(" %-26s %3d raw %3d stripped" % (name, r, c))
chk("the app-wide count went DOWN against the wave 0 baseline of 79",
app_raw < 79, app_raw)
chk("...and the wizard contributes none of what is left",
"work-package-suite-app.js" not in per_file, sorted(per_file))
print("\n both widths")
for w, label in ((390, "390px"), (1440, "1440px")):
page.viewport(w, 900, mobile=(w == 390))
open_wizard(page, base, tok)
blank(page, STEP_FIELDS[1])
page.eval("validateStep(1)")
settle(0.6)
chk("%s: the error renders under its field" % label, page.eval("""(() => {
const f = document.getElementById('proj_name').getBoundingClientRect();
const e = document.getElementById('proj_name_err').getBoundingClientRect();
return e.height > 0 && e.top >= f.bottom - 2;
})()"""))
page.eval("wizardToast('probe message at %s')" % label)
settle(0.4)
chk("%s: an announcement stays inside the viewport" % label, page.eval("""(() => {
const r = document.getElementById('wp-toast').getBoundingClientRect();
return r.left >= -1 && r.right <= window.innerWidth + 1 && r.width > 0;
})()"""), page.eval("JSON.stringify(document.getElementById('wp-toast')"
".getBoundingClientRect())"))
chk("%s: the page does not scroll sideways" % label,
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"))
page.viewport(1400, 1000)
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-validation-")
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("\nInline validation — S1 (wizard)\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 — errors at the field, and no dialogs left.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())