Files
Project-SDE-WP-Suite/tests/generalinfo_check.py
n.siegfried 755c976841 T7.2 - F6/D3: the form gets structure - a section rail, one section open
F6 as amended by D3 (Aug 18): one page, persistent side navigation, sections
collapsible, only the current one open by default, plus Expand all. Tabs were
rejected in D3 because they hide sections a first-time author does not know
exist.

What changed:
- The jump-chip strip (#section-nav, span onclick) is gone. In its place a
  <nav> section rail of real <button> entries, aria-current on the current
  section, 44px tap targets, above the form at 390px and beside it at 1440px.
- Every section heading is now a disclosure <button> with aria-expanded and
  aria-controls. One section open at rest; Expand all (aria-pressed) opens
  everything and is remembered per browser.
- Sections are URL-addressable (?section=, T4.2 machinery) and a deep link to
  a collapsed section expands it. Positional-id fallback removed: a card
  without an id gets a console.error and no rail entry, never an invented
  sec-N id that would ride into shareable URLs and move between visits.
- General Information (1,288px on its own) split into #general-card and
  #assign-card (Assignment & Schedule). The split is presentational: both
  cards are the ONE CR-006 section `general` (WP_SECTION_NODES lists both),
  so wp-sections.js and the SOP wizard are untouched. CR-001's adjacency
  (P6 activity beside due date) is preserved and asserted.
- gotoSection() flushes autosave, which the deleted chips used to do.
- secMakeToggle() preserves every element child of a heading - help tips go
  outside the button, everything else inside the label. The first version
  cleared textContent and destroyed #saved-count, which killed boot one line
  short of wpCreatorReady with the page still visibly rendered.
- BL-013 folded in per the task: the T3.4 focus ring on the rebuilt form.
  frame_check reports outline solid 2px on creator inputs.

Height, measured not asserted: 5,399px before; 1,995px at rest at 1440x900.

DONE-WHEN NOT FULLY MET - stated per CLAUDE.md rather than marked complete:
"no single view exceeds roughly two screen heights at rest" reads 2.22
screens (1995/900). The remaining gap is page chrome this wave reworks:
.ctx-bar (67px, T7.4) and .release-banner (45px, T7.5). The criterion was
already amended once (D3, "at rest") and is not being moved again to fit;
tests/form_structure_check.py keeps the check red and it is re-measured at
the end of wave 7. Every other done-when entry passes.

Backlog: BL-001's cause corrected a third time - at rest the overflow is
help.js's .help-tip::after tooltip (481 vs 390), the S8 component T9.5
rebuilds; the tables still overflow only when expanded. Deliberately not
fixed here - a fix would be thrown away with the component at T9.5.

Verification (each probe run alone): form_structure_check 50/51 (the height
check above), sections_check 95/95, generalinfo_check 49/49, frame_check
39/39 regression pass.

Items: F6, D3, BL-013, BL-001 (re-measured)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 09:38:31 -07:00

490 lines
22 KiB
Python

#!/usr/bin/env python3
"""P6 activity and priority on a work package — CR-001, CR-003 (T6.1, T6.2).
Two fields, one board. What they share is the board column, so they share a
probe: the sort mechanism CR-001 needed did not exist, CR-003 reuses it, and a
sort that works for one column and not the other is the failure worth catching.
CR-001 both fields exist, persist and survive a reload
Activity ID renders next to Due Date on the detail view
the board column sorts correctly, INCLUDING with empty values
both fields appear on the PDF export
the fields respect the CR-006 section toggles
CR-003 exactly three values; Normal is the default on a new package
the dashboard filters and sorts by priority
priority colours come from canonical tokens; no raw hex added
colour is not the only signal — the label is always present
priority prints on the export
changing priority does not alter status
"Sorts correctly with empty values" is the check with teeth, and the answer it
demands is a decision rather than a behaviour: blanks sort LAST in both
directions. Ascending by P6 activity means "the ones with an activity, then the
ones without", because nobody sorts by a column to look at its empty rows.
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
"""
# Deliberately unsorted, deliberately with two blanks, and deliberately with a
# package that predates CR-003 (no priority key at all).
BOARD = [
{"id": "b1", "number": "WP01", "subject": "one", "type": "Conduit Install",
"status": "Draft", "p6Id": "A3000", "p6Desc": "third activity",
"priority": "Urgent", "hours": "10", "constraints": []},
{"id": "b2", "number": "WP02", "subject": "two", "type": "Conduit Install",
"status": "Draft", "p6Id": "A1000", "p6Desc": "first activity",
"priority": "Normal", "hours": "5", "constraints": []},
{"id": "b3", "number": "WP03", "subject": "three", "type": "Conduit Install",
"status": "Draft", "p6Id": "", "priority": "High", "hours": "7",
"constraints": []},
{"id": "b4", "number": "WP04", "subject": "four", "type": "Conduit Install",
"status": "Draft", "p6Id": "A2000", "p6Desc": "second activity",
"hours": "3", "constraints": []}, # no priority key: predates CR-003
{"id": "b5", "number": "WP05", "subject": "five", "type": "Conduit Install",
"status": "Draft", "priority": "Normal", "hours": "1", "constraints": []},
]
# browser_check's fixture stores a bare {governance: ...} SOP blob, which the
# creator cannot read (BL-018) — so it boots with no WP types and savePackage()
# refuses for a reason nothing to do with this task. Production writes
# {sop, state}; so does this.
SOP_DATA = {
"sop": {"meta": {"tool": "Work Package Configuration", "sample": False},
"project": {"name": "Job A", "number": "A-1", "client": "Internal QA"},
"governance": {"disciplines": ["Mechanical", "Electrical"],
"woFormat": "WP##-[TYPE]"},
"woTypes": [{"name": "Conduit Install", "enabled": True},
{"name": "Wire Pull", "enabled": True}]},
"state": {"project": {"name": "Job A", "number": "A-1", "client": "Internal QA",
"division": "Internal", "site": "QA Lab"},
"team": {"pm": "", "apm": "", "cm": "", "qm": ""},
"teamIds": {"pm": "", "apm": "", "cm": "", "qm": ""}, "teamMembers": [],
"signoffRoles": [{"role": "Superintendent", "name": ""}],
"wpTypes": [{"name": "Conduit Install", "enabled": True}],
"governance": {"woformat": "WP##-[TYPE]", "wosize": "", "issuance": [],
"disciplines": ["Mechanical", "Electrical"],
"discMode": "choice", "instanceSuffix": "letter",
"sizeHoursMax": ""},
"quality": {"qcreq": "Yes", "photo": "", "hold": ""},
"platforms": {"tracking": "CxAlloy", "commissioning": "CxAlloy",
"trackingUrl": "", "commissioningUrl": ""},
"constraints": [], "sequence": [], "sources": []},
}
def seed_with_sop(db_path):
tok = seed(db_path)
from server.db import SessionLocal
from server import models
with SessionLocal() as db:
db.get(models.Sop, "sopA").data = SOP_DATA
db.commit()
return tok
def settle(seconds=1.2):
time.sleep(seconds)
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
def open_creator(page, base, tok, query="?project=projA"):
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.goto(base + "/wp-creation-index.html" + query)
ok = wait_creator(page)
settle(1.4)
page.eval(STUB)
return ok
def load_board(page):
"""Put a known set of packages in front of the board and open it."""
page.eval("savedPackages = %s; saveStore(); showDashboard(); true" % json.dumps(BOARD))
for _ in range(30):
if page.eval("!!document.querySelector('.dash-table')"):
break
time.sleep(0.3)
settle(1.0)
def board_column(page, header):
"""The visible cells of one board column, top to bottom, by header text."""
return json.loads(page.eval("""JSON.stringify((() => {
const panel = [...document.querySelectorAll('.dash-panel')].find(p => {
const t = p.querySelector('.dash-panel-title');
return t && /^Work packages/.test(t.textContent.trim());
});
if (!panel) return null;
const heads = [...panel.querySelectorAll('thead th')]
.map(th => (th.textContent || '').replace(/[\\u25b2\\u25bc]/g, '').trim());
const ix = heads.indexOf(%s);
if (ix < 0) return null;
return [...panel.querySelectorAll('tbody tr')]
.map(r => r.cells[ix] ? r.cells[ix].textContent.trim() : null);
})())""" % json.dumps(header)))
def click_header(page, header):
page.eval("""(() => {
const panel = [...document.querySelectorAll('.dash-panel')].find(p => {
const t = p.querySelector('.dash-panel-title');
return t && /^Work packages/.test(t.textContent.trim());
});
const th = [...panel.querySelectorAll('thead th')].find(
t => (t.textContent||'').replace(/[\\u25b2\\u25bc]/g,'').trim() === %s);
th.querySelector('.dash-sort').click();
return true;
})()""" % json.dumps(header))
settle(0.7)
def run(page, base, tok):
print("\nCR-001 (T6.1). P6 activity ID and description")
open_creator(page, base, tok)
chk("the creator boots with no JavaScript error",
not [e for e in page.js_errors() if "/api/sops/latest" not in e], page.js_errors())
chk("both fields exist in General Information",
page.eval("!!document.getElementById('wp_p6_id') && !!document.getElementById('wp_p6_desc')"))
chk("...and the activity sits beside the due date, not at the bottom",
page.eval("""(() => {
const due = document.getElementById('wp_due').closest('.field');
const p6 = document.getElementById('wp_p6_id').closest('.field');
return due.nextElementSibling === p6;
})()"""))
# Re-pointed at T7.2, not relaxed. This used to assert DOM containment in
# #general-card, and T7.2 split that card in two - so the assertion would now
# fail while the thing it claims to check is still true. What it is FOR is
# "the CR-006 toggle governs them", so it checks that: both fields live under
# a node the `general` section owns, whichever card that is.
chk("...owned by the General Information section, so its CR-006 toggle governs them",
page.eval("""(() => {
const owners = ['#general-card', '#assign-card']
.map(sel => document.querySelector(sel)).filter(Boolean);
const covered = id => {
const el = document.getElementById(id);
return !!el && owners.some(o => o.contains(el));
};
return covered('wp_p6_id') && covered('wp_p6_desc');
})()"""))
print(" they persist, and survive a reload")
page.eval("""(() => {
newPackage();
document.getElementById('wp_subject').value = 'P6 probe package';
document.getElementById('wp_type').value = document.getElementById('wp_type').options[1]
? document.getElementById('wp_type').options[1].value : '';
document.getElementById('wp_p6_id').value = 'A1234';
document.getElementById('wp_p6_desc').value = 'Level 2 rough-in';
return true;
})()""")
collected = json.loads(page.eval(
"JSON.stringify((({p6Id, p6Desc}) => ({p6Id, p6Desc}))(collectPackage()))"))
chk("collectPackage carries both", collected == {"p6Id": "A1234", "p6Desc": "Level 2 rough-in"},
collected)
page.eval("savePackage()")
settle(1.6)
page.goto(base + "/wp-creation-index.html?project=projA")
chk("the creator reloads", wait_creator(page))
settle(1.4)
page.eval(STUB)
round_trip = json.loads(page.eval("""JSON.stringify((() => {
const p = savedPackages.find(x => x.subject === 'P6 probe package');
return p ? {p6Id: p.p6Id, p6Desc: p.p6Desc} : null;
})())"""))
chk("...and both survived it", round_trip == {"p6Id": "A1234", "p6Desc": "Level 2 rough-in"},
round_trip)
page.eval("""(() => {
const i = savedPackages.findIndex(x => x.subject === 'P6 probe package');
editPackage(i);
return true;
})()""")
settle(0.8)
chk("...and are back in the form when the package is opened",
page.eval("document.getElementById('wp_p6_id').value") == "A1234"
and page.eval("document.getElementById('wp_p6_desc').value") == "Level 2 rough-in",
[page.eval("document.getElementById('wp_p6_id').value"),
page.eval("document.getElementById('wp_p6_desc').value")])
print(" the detail view and the export")
page.eval("""(() => {
const p = savedPackages.find(x => x.subject === 'P6 probe package');
renderPackage(p);
return true;
})()""")
settle(0.7)
doc = page.eval("document.getElementById('pkg-doc').innerHTML")
chk("the activity id appears in the document", "A1234" in doc)
chk("...and the description with it", "Level 2 rough-in" in doc)
chk("...next to the due date, where the schedule driver is visible without scrolling",
re.search(r"Due Date.{0,200}A1234", doc, re.S) is not None,
re.findall(r"Due Date.{0,120}", doc, re.S)[:1])
chk("the PDF export is that same document, so it carries them too",
page.eval("document.getElementById('pkg-doc').innerHTML") == doc)
print(" ...and disappear with General Information, because they are in it")
page.eval("applySopSections({general: false}, {})")
settle(0.4)
page.eval("""(() => {
renderPackage(savedPackages.find(x => x.subject === 'P6 probe package'));
return true;
})()""")
settle(0.7)
off_doc = page.eval("document.getElementById('pkg-doc').innerHTML")
chk("neither is in the document while the section is off",
"A1234" not in off_doc and "Level 2 rough-in" not in off_doc)
page.eval("applySopSections({}, {})")
print("\n the board column, and how it sorts nothing")
load_board(page)
heads = json.loads(page.eval("""JSON.stringify([...document.querySelectorAll(
'.dash-panel .dash-table thead th')].map(
th => (th.textContent||'').replace(/[\\u25b2\\u25bc]/g,'').trim()))"""))
chk("the board has a P6 activity column", "P6 activity" in heads, heads)
chk("...and every sortable header is a real button, not a div with a handler",
page.eval("""[...document.querySelectorAll('.dash-panel .dash-table thead th')]
.filter(th => th.hasAttribute('aria-sort'))
.every(th => !!th.querySelector('button.dash-sort'))"""))
chk("...with aria-sort on the th, so the direction is exposed not just drawn",
page.eval("""[...document.querySelectorAll('.dash-panel .dash-table thead th[aria-sort]')]
.length""") >= 6)
click_header(page, "P6 activity")
asc = board_column(page, "P6 activity")
chk("ascending puts the activities in order", asc[:3] == ["A1000", "A2000", "A3000"], asc)
chk("...and the two blanks LAST, not first", all(v in ("", "") for v in asc[3:]), asc)
click_header(page, "P6 activity")
desc = board_column(page, "P6 activity")
chk("descending reverses the filled rows", desc[:3] == ["A3000", "A2000", "A1000"], desc)
chk("...and leaves the blanks last, in both directions",
all(v in ("", "") for v in desc[3:]), desc)
chk("...which is a decision, not an accident: no row is lost either way",
len(asc) == len(BOARD) and len(desc) == len(BOARD), [len(asc), len(desc)])
chk("the sorted header says so through aria-sort",
page.eval("""(() => {
const th = [...document.querySelectorAll('.dash-panel .dash-table thead th')]
.find(t => /P6 activity/.test(t.textContent));
return th.getAttribute('aria-sort');
})()""") == "descending")
chk("...and the other columns say none",
page.eval("""[...document.querySelectorAll('.dash-panel .dash-table thead th[aria-sort]')]
.filter(t => t.getAttribute('aria-sort') !== 'none').length""") == 1)
print("\nCR-003 (T6.2). Priority")
chk("exactly three values are offered", json.loads(page.eval(
"JSON.stringify([...document.getElementById('wp_priority').options].map(o=>o.value))"))
== ["Normal", "High", "Urgent"],
page.eval("JSON.stringify([...document.getElementById('wp_priority').options]"
".map(o=>o.value))"))
chk("...and no fourth anywhere in the source",
json.loads(page.eval("JSON.stringify(WP_PRIORITIES)")) == ["Normal", "High", "Urgent"])
page.eval("showForm(); newPackage()")
settle(0.8)
chk("a new package defaults to Normal",
page.eval("document.getElementById('wp_priority').value") == "Normal")
chk("...and collectPackage says Normal too",
json.loads(page.eval("JSON.stringify(collectPackage().priority)")) == "Normal")
chk("a package saved before CR-003 reads as Normal, not blank",
page.eval("wpPriorityOf({number:'old'})") == "Normal")
chk("...and so does one with a value that is not on the list",
page.eval("wpPriorityOf({priority:'Critical'})") == "Normal")
print(" the board sorts by escalation, not alphabetically")
load_board(page)
click_header(page, "Priority")
prio_asc = board_column(page, "Priority")
chk("ascending runs Normal -> High -> Urgent",
prio_asc == ["Normal", "Normal", "Normal", "High", "Urgent"], prio_asc)
chk("...which is NOT alphabetical, and that is the point",
prio_asc != sorted(prio_asc), prio_asc)
click_header(page, "Priority")
chk("descending puts Urgent first", board_column(page, "Priority")[0] == "Urgent",
board_column(page, "Priority"))
print(" ...and filters by it")
page.eval("dashFilter.priority='Urgent'; dashPage=0; renderDashboard()")
settle(0.7)
only = board_column(page, "Priority")
chk("filtering to Urgent leaves only Urgent", only == ["Urgent"], only)
page.eval("dashFilter.priority='Normal'; dashPage=0; renderDashboard()")
settle(0.7)
chk("...and Normal includes the package that predates the field",
len(board_column(page, "Priority")) == 3, board_column(page, "Priority"))
page.eval("dashFilter.priority=''; dashPage=0; renderDashboard()")
settle(0.5)
chk("...clearing the filter brings everything back",
len(board_column(page, "Priority")) == len(BOARD))
chk("the filter is offered as a labelled control",
page.eval("""!![...document.querySelectorAll('.dash-filters select')]
.find(s => (s.getAttribute('aria-label')||'').toLowerCase().includes('priority'))"""))
print(" colour is never the only signal, and none of it is a raw hex")
pills = json.loads(page.eval("""JSON.stringify(
[...document.querySelectorAll('.dash-panel .dash-table .prio')].map(e => ({
text: (e.textContent||'').trim(),
color: getComputedStyle(e).color,
bg: getComputedStyle(e).backgroundColor,
border: getComputedStyle(e).borderTopColor,
})))"""))
chk("every priority cell carries its label in words",
pills and all(p["text"] in ("Normal", "High", "Urgent") for p in pills),
[p["text"] for p in pills])
by_text = {p["text"]: p for p in pills}
chk("...and the three differ by fill as well as by hue",
len({by_text[k]["bg"] for k in by_text}) == len(by_text),
{k: by_text[k]["bg"] for k in by_text})
src = re.sub(r"/\*.*?\*/", "",
open(os.path.join(ROOT, "html", "wp-creation-styles.css"),
encoding="utf-8").read(), flags=re.S)
chk("no colour literal was added to the creator's stylesheet",
not re.findall(r"#[0-9a-fA-F]{3,8}\b|\brgba?\(", src),
re.findall(r"#[0-9a-fA-F]{3,8}\b|\brgba?\(", src)[:4])
chk("...and the priority rules consume tokens only",
all("var(--" in ln for ln in re.findall(r"\.prio[^{]*\{([^}]*)\}", src)
for ln in [ln] if "color" in ln),
re.findall(r"\.prio[^{]*\{[^}]*\}", src)[:2])
print(" priority is independent of status")
page.eval("showForm(); newPackage()")
settle(0.6)
before = page.eval("getRadio('status')")
page.eval("""(() => {
const p = document.getElementById('wp_priority');
p.value = 'Urgent';
p.dispatchEvent(new Event('change', {bubbles: true}));
return true;
})()""")
settle(0.5)
chk("changing priority does not move status",
page.eval("getRadio('status')") == before,
[before, page.eval("getRadio('status')")])
chk("...and collectPackage reports the new priority with the old status",
json.loads(page.eval("JSON.stringify((({priority,status}) => ({priority,status}))"
"(collectPackage()))")) == {"priority": "Urgent", "status": before},
page.eval("JSON.stringify((({priority,status}) => ({priority,status}))(collectPackage()))"))
print(" and it prints")
page.eval("renderPackage(%s)" % json.dumps(
dict(BOARD[0], subject="urgent probe", constraints=[], signoffs=[])))
settle(0.7)
pdoc = page.eval("document.getElementById('pkg-doc').innerHTML")
chk("priority appears in the document", re.search(r"Priority.{0,80}Urgent", pdoc, re.S)
is not None, re.findall(r"Priority.{0,60}", pdoc, re.S)[:1])
chk("...and a package with no priority prints Normal rather than blank",
page.eval("""(() => {
renderPackage({id:'x', number:'WPX', subject:'no priority', status:'Draft',
constraints:[], signoffs:[]});
const d = document.getElementById('pkg-doc').innerHTML;
return /Priority[\\s\\S]{0,80}Normal/.test(d);
})()"""))
chk("no native dialog was opened anywhere in this flow",
not json.loads(page.eval("JSON.stringify(window.__dialogs||[])")),
page.eval("JSON.stringify(window.__dialogs||[])"))
print("\n both widths")
for w, label in ((390, "390px"), (1440, "1440px")):
page.viewport(w, 900, mobile=(w == 390))
open_creator(page, base, tok)
load_board(page)
chk("%s: the board renders with both new columns" % label,
page.eval("""(() => {
const h = [...document.querySelectorAll('.dash-panel .dash-table thead th')]
.map(t => t.textContent);
return h.some(x => /P6 activity/.test(x)) && h.some(x => /Priority/.test(x));
})()"""))
chk("%s: the board does not push the page sideways" % label,
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1")
or w == 390, # the creator overflows at 390 for its own reasons (BL-001)
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
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-geninfo-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed_with_sop(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("\nP6 activity and priority — CR-001 / CR-003\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 — the schedule driver and the urgency are both on it.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())