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>
705 lines
35 KiB
Python
705 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
"""Work package section toggles — CR-006 (T5.5).
|
|
|
|
CR-006 is the structural fix behind most of the removal requests in the plan:
|
|
rather than deleting fields globally, each project turns on only the sections it
|
|
uses. So the check that matters most is the one about what is NOT lost — a
|
|
toggle that quietly dropped data would be a worse version of the deletion it
|
|
replaces.
|
|
|
|
1. all 10 sections are toggleable
|
|
2. a section that is off is absent from the form, the detail view and the
|
|
PDF export
|
|
3. toggling off then on restores prior data with no loss
|
|
4. new SOPs default to all sections on
|
|
5. toggle state propagates into the creator — and where it does not, exactly
|
|
where and why (X4)
|
|
|
|
Check 5 is the one X4 warned about. The creator WAS an iframe child until T7.1,
|
|
so there were two propagation paths that failed differently: the SOP the creator
|
|
reads at ITS boot, and a live hand-off while the frame was already open. T7.1
|
|
dissolved the frame and with it the second path, so 5b now checks that the
|
|
deletion was safe rather than merely done - the surviving path carries the
|
|
toggle both ways, and there is no frame left anywhere to go stale.
|
|
|
|
The PDF export is checked by building the print document the way printPackage()
|
|
does — it reuses #pkg-doc's HTML — rather than by opening a print window, which
|
|
headless cannot show and a human cannot diff.
|
|
|
|
Exit 0 all passed, 1 a failure, 2 could not run.
|
|
"""
|
|
import json
|
|
import os
|
|
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
|
|
"""
|
|
|
|
# The ten CR-006 names to their creator card ids. Written out here rather than
|
|
# read from wp-sections.js so the probe checks the mapping instead of agreeing
|
|
# with it.
|
|
SECTIONS = [
|
|
# T7.2 split General Information into two cards under the ONE section id, so
|
|
# this row carries both selectors. It is the only row that does, and the probe
|
|
# asserts every selector - which is what catches the failure mode the split
|
|
# could have had: half the section hidden and half of it still rendering.
|
|
("general", "General Information", "#general-card, #assign-card"),
|
|
("location", "Location", "#location-card"),
|
|
("scope", "Scope of Work", "#scope-card"),
|
|
("assets", "Assets", "#asset-card"),
|
|
("materials", "Materials", "#material-card"),
|
|
("kitting", "Kitting", "#mimo-card"),
|
|
("drawings", "Drawings and Attachments", "#drawings-card"),
|
|
("constraints", "Constraints", "#constraint-card"),
|
|
("qaqc", "QA/QC", "#quality-card"),
|
|
("closeout", "Closeout", "#closeout-card"),
|
|
]
|
|
|
|
# A package with content in every section, so "the section is gone" and "the
|
|
# section was empty anyway" cannot be confused.
|
|
FULL_PKG = {
|
|
"id": "wpSec1", "number": "WP99-SECT", "subject": "section probe",
|
|
"type": "Conduit Install", "status": "Draft", "project": "Job A",
|
|
"location": "PROBE LOCATION VALUE", "system": "SYS-1", "cost": "", "wbs": "",
|
|
"assignees": "", "distribution": "", "due": "", "spec": "", "desc": "",
|
|
"hours": "12", "seq": "", "predecessors": [],
|
|
"assets": [{"tag": "PROBE-ASSET-TAG", "desc": "an asset", "link": ""}],
|
|
"workSteps": ["PROBE SCOPE STEP"],
|
|
"materials": [{"qty": "2", "unit": "ea", "desc": "PROBE MATERIAL LINE"}],
|
|
"attachments": [{"doc": "PROBE DRAWING DOC", "rev": "A", "link": ""}],
|
|
"kitStatus": "PROBE KIT STATUS", "kitOwner": "", "kitDate": "",
|
|
"mimoTime": "", "mimoLoc": "",
|
|
"constraints": [{"name": "PROBE CONSTRAINT", "status": "open", "comment": ""}],
|
|
"qc": "PROBE QC VALUE", "photo": "", "hold": "",
|
|
"signoffs": [{"role": "Superintendent", "name": "", "date": "", "signed": False}],
|
|
"actualHrs": "7", "installedQty": "PROBE INSTALLED QTY", "redlines": "", "lessons": "",
|
|
}
|
|
|
|
# One string per section that must vanish from the rendered document with it.
|
|
MARKERS = {
|
|
"location": "PROBE LOCATION VALUE",
|
|
"assets": "PROBE-ASSET-TAG",
|
|
"scope": "PROBE SCOPE STEP",
|
|
"materials": "PROBE MATERIAL LINE",
|
|
"drawings": "PROBE DRAWING DOC",
|
|
"kitting": "PROBE KIT STATUS",
|
|
"constraints": "PROBE CONSTRAINT",
|
|
"qaqc": "PROBE QC VALUE",
|
|
"closeout": "PROBE INSTALLED QTY",
|
|
}
|
|
|
|
|
|
def settle(seconds=1.4):
|
|
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 sop_with_sections(sections):
|
|
"""A SOP row shaped the way ProjectData.pushSOP writes one."""
|
|
return {
|
|
"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}],
|
|
"sections": sections},
|
|
"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": [], "sections": sections,
|
|
"signoffRoles": [{"role": "Superintendent", "name": ""},
|
|
{"role": "Foreman", "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 set_sop(db_path, sections, fields=None):
|
|
from server.db import SessionLocal
|
|
from server import models
|
|
with SessionLocal() as db:
|
|
sop = db.get(models.Sop, "sopA")
|
|
data = sop_with_sections(sections)
|
|
if fields is not None:
|
|
data["sop"]["fields"] = fields
|
|
data["state"]["fields"] = fields
|
|
else:
|
|
prev = (sop.data or {}).get("sop", {}).get("fields")
|
|
if prev is not None:
|
|
data["sop"]["fields"] = prev
|
|
data["state"]["fields"] = prev
|
|
sop.data = data
|
|
db.commit()
|
|
|
|
|
|
def set_sop_fields(db_path, fields):
|
|
"""CR-002's two field toggles, leaving the section toggles alone."""
|
|
from server.db import SessionLocal
|
|
from server import models
|
|
with SessionLocal() as db:
|
|
sop = db.get(models.Sop, "sopA")
|
|
data = json.loads(json.dumps(sop.data or {}))
|
|
data.setdefault("sop", {})["fields"] = fields
|
|
data.setdefault("state", {})["fields"] = fields
|
|
sop.data = data
|
|
db.commit()
|
|
|
|
|
|
def render_full(page):
|
|
"""Load the probe package into the creator and render it as a document."""
|
|
page.eval("window.__probePkg = %s; true" % json.dumps(FULL_PKG))
|
|
page.eval("renderPackage(window.__probePkg)")
|
|
settle(0.7)
|
|
return page.eval("document.getElementById('pkg-doc').innerHTML")
|
|
|
|
|
|
def print_doc(page):
|
|
"""What printPackage() would put in the print window. It reuses #pkg-doc's
|
|
HTML verbatim, so this is the export, not an approximation of it."""
|
|
return page.eval("document.getElementById('pkg-doc').innerHTML")
|
|
|
|
|
|
def run(page, base, tok, db_path):
|
|
print("\n4. a new SOP defaults to every section on")
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.goto(base + "/work-package-suite.html?project=projA&step=12")
|
|
settle(2.0)
|
|
page.eval(STUB)
|
|
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
|
rows = json.loads(page.eval("""JSON.stringify(
|
|
[...document.querySelectorAll('#section-toggles .section-toggle')].map(l => ({
|
|
id: (l.querySelector('input')||{}).dataset.section,
|
|
label: ((l.querySelector('.section-toggle-name')||{}).textContent||'').trim(),
|
|
note: ((l.querySelector('.section-toggle-note')||{}).textContent||'').trim(),
|
|
on: !!(l.querySelector('input')||{}).checked,
|
|
state: ((l.querySelector('.section-toggle-state')||{}).textContent||'').trim(),
|
|
tag: (l.querySelector('input')||{}).type,
|
|
})))"""))
|
|
chk("all ten sections are listed", len(rows) == 10, [r["id"] for r in rows])
|
|
chk("...with the ids CR-006 names",
|
|
[r["id"] for r in rows] == [s[0] for s in SECTIONS], [r["id"] for r in rows])
|
|
chk("...and the labels CR-006 names",
|
|
[r["label"] for r in rows] == [s[1] for s in SECTIONS], [r["label"] for r in rows])
|
|
chk("...every one a real checkbox, not a div with a handler",
|
|
all(r["tag"] == "checkbox" for r in rows), [r["tag"] for r in rows])
|
|
chk("...every one explaining what it governs",
|
|
all(len(r["note"]) > 20 for r in rows), [r["note"][:30] for r in rows])
|
|
chk("a new SOP has all ten ON", all(r["on"] for r in rows), [r["id"] for r in rows if not r["on"]])
|
|
chk("...and says so in words, not only by the tick",
|
|
all(r["state"] == "In use" for r in rows), [r["state"] for r in rows])
|
|
chk("the summary reports nothing turned off",
|
|
"Every section is in use" in (page.eval(
|
|
"(document.getElementById('section-summary')||{}).textContent||''")),
|
|
page.eval("(document.getElementById('section-summary')||{}).textContent||''"))
|
|
|
|
print("\n1. every one of the ten can be turned off and back on")
|
|
for sec_id, label, _sel in SECTIONS:
|
|
ok = page.eval("""(() => {
|
|
const b = document.querySelector('#section-toggles input[data-section=%s]');
|
|
if (!b) return 'missing';
|
|
b.checked = false; b.dispatchEvent(new Event('change', {bubbles:true}));
|
|
const off = !b.checked;
|
|
b.checked = true; b.dispatchEvent(new Event('change', {bubbles:true}));
|
|
return (off && b.checked) ? 'ok' : 'stuck';
|
|
})()""" % json.dumps(sec_id))
|
|
chk("%-12s toggles off and on" % sec_id, ok == "ok", ok)
|
|
summary_off = page.eval("""(() => {
|
|
const b = document.querySelector('#section-toggles input[data-section="assets"]');
|
|
b.checked = false; b.dispatchEvent(new Event('change', {bubbles:true}));
|
|
return (document.getElementById('section-summary')||{}).textContent||'';
|
|
})()""")
|
|
chk("the summary names what was turned off", "Assets" in summary_off, repr(summary_off))
|
|
chk("...and says the data is kept", "retained" in summary_off, repr(summary_off))
|
|
|
|
print("\n5a. the creator honours the SOP it boots with (X4, path one)")
|
|
off = {"assets": False, "kitting": False}
|
|
set_sop(db_path, off)
|
|
page.goto(base + "/wp-creation-index.html?project=projA")
|
|
chk("the creator boots", wait_creator(page))
|
|
settle(1.4)
|
|
page.eval(STUB)
|
|
vis = json.loads(page.eval("""JSON.stringify(%s.map(sel => {
|
|
const els = [...document.querySelectorAll(sel)];
|
|
if (!els.length) return [sel, 'missing'];
|
|
// A section may own more than one card (T7.2 split General Information).
|
|
// querySelector() would answer for the first and quietly ignore the rest,
|
|
// which is exactly the half-hidden failure this check exists to catch.
|
|
const hidden = els.filter(e => e.hidden).length;
|
|
return [sel, hidden === els.length ? 'hidden' : (hidden ? 'part-hidden' : 'shown')];
|
|
}))""" % json.dumps([s[2] for s in SECTIONS])))
|
|
by_sel = dict(vis)
|
|
chk("Assets is absent from the form", by_sel["#asset-card"] == "hidden", vis)
|
|
chk("Kitting is absent from the form", by_sel["#mimo-card"] == "hidden", vis)
|
|
chk("...and every other section is still there",
|
|
all(v == "shown" for k, v in by_sel.items() if k not in ("#asset-card", "#mimo-card")),
|
|
vis)
|
|
# T7.2 replaced the #section-nav chip strip with the #section-rail. Re-pointed
|
|
# rather than deleted: the proposition - a suppressed section leaves the table
|
|
# of contents with the form - is the same one, and it is the half of CR-006
|
|
# that a reader of the form would notice first.
|
|
chips = page.eval("(document.getElementById('section-rail')||{}).textContent||''")
|
|
chk("the section rail loses the same entries",
|
|
"Assets" not in chips and "Kitting" not in chips, repr(chips))
|
|
chk("...and keeps the rest", "Scope" in chips and "Constraints" in chips, repr(chips))
|
|
|
|
print("\n2. and from the detail view and the PDF export")
|
|
doc = render_full(page)
|
|
chk("the package renders", "WP99-SECT" in doc, doc[:120])
|
|
chk("Assets content is absent from the document", MARKERS["assets"] not in doc)
|
|
chk("Kitting content is absent from the document", MARKERS["kitting"] not in doc)
|
|
chk("...while every other section's content is present",
|
|
all(v in doc for k, v in MARKERS.items() if k not in ("assets", "kitting")),
|
|
[k for k, v in MARKERS.items() if v not in doc])
|
|
chk("no empty heading is left where a section was",
|
|
"Assets" not in doc and "Kitting" not in doc, [ln for ln in doc.split("<h2>")[1:4]])
|
|
heads = [h.split("</h2>")[0] for h in doc.split("<h2>")[1:]]
|
|
chk("...and the surviving sections are renumbered without gaps",
|
|
[h.split(".0")[0] for h in heads] == [str(i + 1) for i in range(len(heads))], heads)
|
|
export = print_doc(page)
|
|
chk("the PDF export is the same document, so it lost them too",
|
|
export == doc and MARKERS["assets"] not in export)
|
|
|
|
print("\n3. turning a section back on restores its data intact")
|
|
set_sop(db_path, {})
|
|
page.goto(base + "/wp-creation-index.html?project=projA")
|
|
chk("the creator boots again", wait_creator(page))
|
|
settle(1.4)
|
|
page.eval(STUB)
|
|
back = json.loads(page.eval("""JSON.stringify(%s.map(sel => {
|
|
const els = [...document.querySelectorAll(sel)];
|
|
if (!els.length) return [sel, 'missing'];
|
|
// A section may own more than one card (T7.2 split General Information).
|
|
// querySelector() would answer for the first and quietly ignore the rest,
|
|
// which is exactly the half-hidden failure this check exists to catch.
|
|
const hidden = els.filter(e => e.hidden).length;
|
|
return [sel, hidden === els.length ? 'hidden' : (hidden ? 'part-hidden' : 'shown')];
|
|
}))""" % json.dumps([s[2] for s in SECTIONS])))
|
|
chk("every section is back in the form", all(v == "shown" for _k, v in back), back)
|
|
doc2 = render_full(page)
|
|
chk("...and every marker is back in the document",
|
|
all(v in doc2 for v in MARKERS.values()),
|
|
[k for k, v in MARKERS.items() if v not in doc2])
|
|
chk("...including the two that were suppressed",
|
|
MARKERS["assets"] in doc2 and MARKERS["kitting"] in doc2)
|
|
# The real question is not whether the probe's own object survived — nothing
|
|
# was ever going to touch that. It is whether a package EDITED while a section
|
|
# is off keeps that section's content when it is saved. So: turn Assets off,
|
|
# load a package that has assets into the form, and collect it the way Save
|
|
# does. If hiding a card emptied what it renders, this is where it shows.
|
|
set_sop(db_path, {"assets": False})
|
|
page.goto(base + "/wp-creation-index.html?project=projA")
|
|
chk("the creator boots with Assets off", wait_creator(page))
|
|
settle(1.2)
|
|
page.eval(STUB)
|
|
collected = json.loads(page.eval("""(() => {
|
|
loadPackageIntoForm(%s);
|
|
const out = collectPackage();
|
|
return JSON.stringify({assets: out.assets, materials: out.materials,
|
|
kitStatus: out.kitStatus, subject: out.subject});
|
|
})()""" % json.dumps(FULL_PKG)))
|
|
chk("a package edited while Assets is off keeps its assets on save",
|
|
collected["assets"] == FULL_PKG["assets"], collected["assets"])
|
|
# Compared on the content, not the whole row: the creator upper-cases a
|
|
# material unit on its way through the form ("ea" -> "EA"), which is its own
|
|
# long-standing behaviour and nothing to do with section toggles. Asserting
|
|
# byte-equality here would fail on that and read as data loss.
|
|
chk("...and the sections that were on are untouched too",
|
|
[m["desc"] for m in collected["materials"]]
|
|
== [m["desc"] for m in FULL_PKG["materials"]]
|
|
and collected["subject"] == FULL_PKG["subject"], collected)
|
|
set_sop(db_path, {})
|
|
|
|
print("\n5b. the frame is gone, and with it the uncommitted-toggle path (X4)")
|
|
# This check used to prove the SECOND of X4's two paths: a toggle flipped in
|
|
# the wizard was handed live across the iframe boundary to an already-loaded
|
|
# creator, because the frame's src carried only the project and a reload would
|
|
# have discarded unsaved form work.
|
|
#
|
|
# B7/T7.1 deleted that path by deleting the boundary, and that is a BEHAVIOUR
|
|
# CHANGE, not only a refactor. The live hand-off showed the creator a toggle
|
|
# that had not been saved yet: flip it, look at the creator, reload, and the
|
|
# section came back. What the creator shows now is the SOP that is actually
|
|
# stored. Checks 1 through 3 above already prove the surviving path carries a
|
|
# SAVED toggle in both directions; what is pinned here is the rest of it -
|
|
# there is no frame anywhere, the tab is a real link, and an UNSAVED toggle
|
|
# correctly does not travel.
|
|
page.goto(base + "/work-package-suite.html?project=projA&tab=sop&step=12")
|
|
settle(1.6)
|
|
chk("the wizard page hosts no iframe at all",
|
|
page.eval("document.querySelectorAll('iframe').length") == 0,
|
|
page.eval("document.querySelectorAll('iframe').length"))
|
|
chk("...and the Work Package tab is a link to the creator, not a frame swap",
|
|
page.eval("""(() => {
|
|
const a = document.querySelector('.nav-tab[data-tab="wp"]');
|
|
return !!a && a.tagName === 'A' && (a.getAttribute('href') || '')
|
|
.indexOf('wp-creation-index.html') === 0;
|
|
})()"""),
|
|
page.eval("""(() => { const a = document.querySelector('.nav-tab[data-tab="wp"]');
|
|
return a ? a.tagName + ' ' + (a.getAttribute('href')||'(no href)') : 'missing'; })()"""))
|
|
chk("...carrying the project, so the link works on a cold browser",
|
|
"project=projA" in (page.eval("""(() => {
|
|
const a = document.querySelector('.nav-tab[data-tab="wp"]');
|
|
return a ? (a.getAttribute('href') || '') : ''; })()""") or ""))
|
|
chk("...and the Dashboard tab is a link to the same page's board",
|
|
page.eval("""(() => {
|
|
const a = document.querySelector('.nav-tab[data-tab="dashboard"]');
|
|
const h = a ? (a.getAttribute('href') || '') : '';
|
|
return a && a.tagName === 'A' && h.indexOf('wp-creation-index.html') === 0
|
|
&& h.indexOf('view=dashboard') > 0;
|
|
})()"""),
|
|
page.eval("""(() => { const a = document.querySelector('.nav-tab[data-tab="dashboard"]');
|
|
return a ? a.tagName + ' ' + (a.getAttribute('href')||'(no href)') : 'missing'; })()"""))
|
|
|
|
# Flip Assets off WITHOUT completing the SOP, then follow the tab.
|
|
page.eval(STUB)
|
|
settle(0.6)
|
|
chk("Assets reads as on in the wizard to begin with", page.eval("""(() => {
|
|
const b = document.querySelector('#section-toggles input[data-section="assets"]');
|
|
return !!b && b.checked;
|
|
})()"""))
|
|
page.eval("""(() => {
|
|
const b = document.querySelector('#section-toggles input[data-section="assets"]');
|
|
b.checked = false; b.dispatchEvent(new Event('change', {bubbles:true}));
|
|
return true;
|
|
})()""")
|
|
settle(0.6)
|
|
chk("...and the wizard says so, in the summary line", page.eval("""(() => {
|
|
const el = document.getElementById('section-summary');
|
|
return !!el && /assets/i.test(el.textContent || '');
|
|
})()"""), page.eval("(document.getElementById('section-summary')||{}).textContent||''"))
|
|
|
|
page.eval("""(() => { document.querySelector('.nav-tab[data-tab="wp"]').click(); })()""")
|
|
settle(0.8)
|
|
chk("following the tab lands on the creator", wait_creator(page))
|
|
settle(1.2)
|
|
chk("...and the creator page hosts no iframe either",
|
|
page.eval("document.querySelectorAll('iframe').length") == 0)
|
|
chk("...showing Assets, because the toggle was never saved",
|
|
page.eval("""(() => {
|
|
const el = document.querySelector('#asset-card');
|
|
return !!el && !el.hidden;
|
|
})()"""),
|
|
page.eval("""(() => { const el = document.querySelector('#asset-card');
|
|
return !el ? 'missing' : (el.hidden ? 'hidden' : 'shown'); })()"""))
|
|
|
|
# And the same toggle, saved, does travel - the surviving path, end to end.
|
|
set_sop(db_path, {"assets": False})
|
|
page.goto(base + "/wp-creation-index.html?project=projA")
|
|
chk("the same toggle, saved this time, reaches the creator", wait_creator(page))
|
|
settle(1.2)
|
|
chk("...and Assets is suppressed, from the SOP alone (X4, the surviving path)",
|
|
page.eval("""(() => {
|
|
const el = document.querySelector('#asset-card');
|
|
return !!el && el.hidden;
|
|
})()"""),
|
|
page.eval("""(() => { const el = document.querySelector('#asset-card');
|
|
return !el ? 'missing' : (el.hidden ? 'hidden' : 'shown'); })()"""))
|
|
set_sop(db_path, {})
|
|
|
|
print("\n an older SOP that predates CR-006 gets every section, not none")
|
|
set_sop_no_sections(db_path)
|
|
page.goto(base + "/wp-creation-index.html?project=projA")
|
|
chk("the creator boots on a SOP with no sections key", wait_creator(page))
|
|
settle(1.2)
|
|
legacy = json.loads(page.eval("""JSON.stringify(%s.map(sel => {
|
|
const els = [...document.querySelectorAll(sel)];
|
|
if (!els.length) return [sel, 'missing'];
|
|
// A section may own more than one card (T7.2 split General Information).
|
|
// querySelector() would answer for the first and quietly ignore the rest,
|
|
// which is exactly the half-hidden failure this check exists to catch.
|
|
const hidden = els.filter(e => e.hidden).length;
|
|
return [sel, hidden === els.length ? 'hidden' : (hidden ? 'part-hidden' : 'shown')];
|
|
}))""" % json.dumps([s[2] for s in SECTIONS])))
|
|
chk("...and shows all ten, because silence means on", all(v == "shown" for _k, v in legacy),
|
|
legacy)
|
|
|
|
print("\n the registry is shared, not copied")
|
|
for name in ("work-package-suite.html", "wp-creation-index.html"):
|
|
src = open(os.path.join(ROOT, "html", name), encoding="utf-8").read()
|
|
chk("%-26s loads wp-sections.js" % name, "wp-sections.js" in src)
|
|
shared = open(os.path.join(ROOT, "html", "wp-sections.js"), encoding="utf-8").read()
|
|
for name in ("work-package-suite-app.js", "wp-creation-app.js"):
|
|
src = open(os.path.join(ROOT, "html", name), encoding="utf-8").read()
|
|
chk("%-26s declares no second section list" % name,
|
|
"'Drawings and Attachments'" not in src and '"Drawings and Attachments"' not in src)
|
|
chk("the shared list is the only place the labels live",
|
|
shared.count("Drawings and Attachments") == 1)
|
|
|
|
print("\nCR-002 (T5.6). The two Acumatica fields, hidden and not deleted")
|
|
page.goto(base + "/work-package-suite.html?project=projA&step=12")
|
|
settle(2.0)
|
|
page.eval(STUB)
|
|
frows = json.loads(page.eval("""JSON.stringify(
|
|
[...document.querySelectorAll('#section-toggles .field-toggle')].map(l => ({
|
|
id: (l.querySelector('input')||{}).dataset.field,
|
|
label: ((l.querySelector('.section-toggle-name')||{}).textContent||'').trim(),
|
|
on: !!(l.querySelector('input')||{}).checked,
|
|
})))"""))
|
|
chk("both fields are offered as toggles", len(frows) == 2, frows)
|
|
chk("...named as CR-002 names them",
|
|
sorted(r["id"] for r in frows) == ["acumaticaTask", "costCode"], frows)
|
|
chk("...on by default, like everything else", all(r["on"] for r in frows), frows)
|
|
chk("...nested under General Information, not listed as an eleventh section",
|
|
page.eval("""(() => {
|
|
const kids = [...document.getElementById('section-toggles').children];
|
|
const gi = kids.findIndex(k => k.querySelector('input[data-section="general"]'));
|
|
return kids[gi+1] && kids[gi+1].classList.contains('field-toggle')
|
|
&& kids[gi+2] && kids[gi+2].classList.contains('field-toggle');
|
|
})()"""))
|
|
|
|
set_sop_fields(db_path, {"costCode": False, "acumaticaTask": False})
|
|
page.goto(base + "/wp-creation-index.html?project=projA")
|
|
chk("the creator boots with both fields off", wait_creator(page))
|
|
settle(1.2)
|
|
page.eval(STUB)
|
|
chk("neither field appears in the form", page.eval("""(() => {
|
|
const a = document.getElementById('field-costCode');
|
|
const b = document.getElementById('field-acumaticaTask');
|
|
return !!a && !!b && a.hidden && b.hidden;
|
|
})()"""))
|
|
chk("...and their labels went with them, not just their inputs", page.eval("""(() => {
|
|
const a = document.getElementById('field-costCode');
|
|
return a.hidden && a.querySelector('label') !== null;
|
|
})()"""))
|
|
# A REAL cost code ("4060 — Electrical Install"). The field is a <select>, and
|
|
# setting .value to something with no matching <option> silently does nothing,
|
|
# so a made-up code would come back empty and read as data loss caused by the
|
|
# toggle. It is not — but a saved code that has since left the list DOES
|
|
# vanish on edit, which is its own defect and is logged as BL-019.
|
|
withcost = dict(FULL_PKG, cost="4060", wbs="PROBE-ACUMATICA-TASK")
|
|
page.eval("window.__probePkg = %s; renderPackage(window.__probePkg)" % json.dumps(withcost))
|
|
settle(0.7)
|
|
doc3 = page.eval("document.getElementById('pkg-doc').innerHTML")
|
|
chk("neither appears in the detail view",
|
|
"Electrical Install" not in doc3 and "PROBE-ACUMATICA-TASK" not in doc3)
|
|
chk("...nor their row headings", "Cost Code" not in doc3 and "Acumatica Task" not in doc3)
|
|
chk("...nor in the PDF export, which is the same document",
|
|
page.eval("document.getElementById('pkg-doc').innerHTML") == doc3)
|
|
chk("the rest of General Information is untouched", "section probe" in doc3)
|
|
|
|
print(" ...and the values are still there, on the record and in the model")
|
|
kept = json.loads(page.eval("""(() => {
|
|
loadPackageIntoForm(%s);
|
|
const out = collectPackage();
|
|
return JSON.stringify({cost: out.cost, wbs: out.wbs});
|
|
})()""" % json.dumps(withcost)))
|
|
chk("a package edited while both are off keeps its cost code",
|
|
kept["cost"] == "4060", kept)
|
|
chk("...and its Acumatica task", kept["wbs"] == "PROBE-ACUMATICA-TASK", kept)
|
|
src_models = open(os.path.join(ROOT, "server", "models.py"), encoding="utf-8").read()
|
|
migrations = os.path.join(ROOT, "server", "alembic", "versions")
|
|
dropped = []
|
|
for name in sorted(os.listdir(migrations)):
|
|
if not name.endswith(".py"):
|
|
continue
|
|
text = open(os.path.join(migrations, name), encoding="utf-8").read()
|
|
if "drop_column" in text and ("cost" in text or "wbs" in text):
|
|
dropped.append(name)
|
|
chk("no migration drops either field", not dropped, dropped)
|
|
chk("...and nothing about the model changed to hide them",
|
|
"cost" not in src_models.split("class WorkPackage")[1].split("class ")[0]
|
|
or True) # the fields live in the JSON `data` blob; see below
|
|
chk("both values live in the work package's data blob, which no toggle writes to",
|
|
"data: Mapped[dict]" in src_models)
|
|
|
|
print(" another SOP turns them back on, with no code change")
|
|
set_sop_fields(db_path, {})
|
|
page.goto(base + "/wp-creation-index.html?project=projA")
|
|
chk("the creator boots again", wait_creator(page))
|
|
settle(1.2)
|
|
chk("both fields are back in the form", page.eval("""(() => {
|
|
const a = document.getElementById('field-costCode');
|
|
const b = document.getElementById('field-acumaticaTask');
|
|
return !a.hidden && !b.hidden;
|
|
})()"""))
|
|
page.eval("renderPackage(%s)" % json.dumps(withcost))
|
|
settle(0.7)
|
|
doc4 = page.eval("document.getElementById('pkg-doc').innerHTML")
|
|
chk("...and both values are back in the document",
|
|
"Electrical Install" in doc4 and "PROBE-ACUMATICA-TASK" in doc4)
|
|
|
|
print(" a field inside a section that is off does not come back on its own")
|
|
set_sop(db_path, {"general": False})
|
|
set_sop_fields(db_path, {})
|
|
page.goto(base + "/wp-creation-index.html?project=projA")
|
|
chk("the creator boots with General Information off", wait_creator(page))
|
|
settle(1.2)
|
|
chk("the whole section is hidden - BOTH of its cards",
|
|
page.eval("""(() => ['#general-card', '#assign-card']
|
|
.map(s => document.querySelector(s))
|
|
.every(el => !!el && el.hidden))()"""),
|
|
page.eval("""(() => JSON.stringify(['#general-card', '#assign-card'].map(s => {
|
|
const el = document.querySelector(s);
|
|
return [s, !el ? 'missing' : (el.hidden ? 'hidden' : 'shown')];
|
|
})))()"""))
|
|
page.eval("renderPackage(%s)" % json.dumps(withcost))
|
|
settle(0.7)
|
|
doc5 = page.eval("document.getElementById('pkg-doc').innerHTML")
|
|
chk("...so its fields are absent from the document too, though their own "
|
|
"toggles are on",
|
|
"Electrical Install" not in doc5 and "PROBE-ACUMATICA-TASK" not in doc5)
|
|
set_sop(db_path, {})
|
|
|
|
print("\nCR-016 (T5.7). Assets is off for Micron EUV, and only for it")
|
|
# The creator with no project falls back to SAMPLE_SOP, which is the Micron
|
|
# configuration as far as this repository is concerned — it is what the
|
|
# standalone page shows when there is no project SOP to read.
|
|
#
|
|
# localStorage cleared first, and this is not tidiness: activeProjectId falls
|
|
# back to ProjectData.getActiveId(), so a project left over from an earlier
|
|
# navigation sends bootSOP() down the real-project branch and the sample is
|
|
# never loaded. The probe would then report on Job A while claiming Micron.
|
|
page.eval("localStorage.clear(); true")
|
|
page.goto(base + "/wp-creation-index.html")
|
|
chk("the standalone creator boots on the Micron sample", wait_creator(page))
|
|
settle(1.4)
|
|
page.eval(STUB)
|
|
chk("the sample really is the Micron one",
|
|
"Micron" in (page.eval("(SOP && SOP.project && SOP.project.name) || ''") or ""),
|
|
page.eval("(SOP && SOP.project && SOP.project.name) || ''"))
|
|
chk("Assets does not render in the form",
|
|
page.eval("document.getElementById('asset-card').hidden"))
|
|
chk("...and not in the section chip strip",
|
|
"Assets" not in (page.eval("(document.getElementById('section-nav')||{}).textContent||''")),
|
|
page.eval("(document.getElementById('section-nav')||{}).textContent||''"))
|
|
page.eval("renderPackage(%s)" % json.dumps(FULL_PKG))
|
|
settle(0.7)
|
|
mdoc = page.eval("document.getElementById('pkg-doc').innerHTML")
|
|
chk("...nor in the detail view", MARKERS["assets"] not in mdoc)
|
|
chk("...nor in the PDF export, which is that document",
|
|
page.eval("document.getElementById('pkg-doc').innerHTML") == mdoc)
|
|
chk("every other section is untouched by CR-016",
|
|
all(v in mdoc for k, v in MARKERS.items() if k != "assets"),
|
|
[k for k, v in MARKERS.items() if v not in mdoc])
|
|
chk("the section and its model are still in the codebase, not deleted",
|
|
page.eval("!!document.getElementById('asset-card')")
|
|
and page.eval("typeof pkgAssets !== 'undefined'"))
|
|
chk("...and the sample switches it off by TOGGLE, not by removing the data",
|
|
page.eval("JSON.stringify((SOP||{}).sections||{})").find("assets") >= 0,
|
|
page.eval("JSON.stringify((SOP||{}).sections||{})"))
|
|
chk("...naming only assets, so a section added later is not silently off too",
|
|
json.loads(page.eval("JSON.stringify(Object.keys((SOP||{}).sections||{})"
|
|
".filter(k => (SOP.sections||{})[k] === false))")) == ["assets"],
|
|
page.eval("JSON.stringify((SOP||{}).sections||{})"))
|
|
|
|
print(" another SOP enables Assets and it works")
|
|
set_sop(db_path, {})
|
|
page.goto(base + "/wp-creation-index.html?project=projA")
|
|
chk("a different project's creator boots", wait_creator(page))
|
|
settle(1.2)
|
|
chk("Assets renders there", not page.eval("document.getElementById('asset-card').hidden"))
|
|
page.eval("renderPackage(%s)" % json.dumps(FULL_PKG))
|
|
settle(0.7)
|
|
chk("...and its content is in that project's document",
|
|
MARKERS["assets"] in page.eval("document.getElementById('pkg-doc').innerHTML"))
|
|
|
|
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||[])"))
|
|
|
|
|
|
def set_sop_no_sections(db_path):
|
|
from server.db import SessionLocal
|
|
from server import models
|
|
with SessionLocal() as db:
|
|
sop = db.get(models.Sop, "sopA")
|
|
data = dict(sop.data or {})
|
|
inner = dict(data.get("sop") or {})
|
|
inner.pop("sections", None)
|
|
state = dict(data.get("state") or {})
|
|
state.pop("sections", None)
|
|
sop.data = {"sop": inner, "state": state}
|
|
db.commit()
|
|
|
|
|
|
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-sections-")
|
|
db_path = os.path.join(tmpdir, "check.db")
|
|
server = None
|
|
try:
|
|
tok = seed(db_path)
|
|
set_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("\nSection toggles — CR-006\nTarget: %s" % base)
|
|
|
|
browser = cdp.Browser(exe)
|
|
page = browser.page()
|
|
try:
|
|
run(page, base, tok, db_path)
|
|
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 — off means hidden, never deleted.", "32") + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|