Files
Project-SDE-WP-Suite/tests/export_check.py
n.siegfried c2a1cc7c26 T9.2 - CR-017: Actual Hours is still there, still optional, still counted
A guard, not a build. Removal was floated in the meeting and rejected -
Marlena tracks actual hours so they can be measured - and CLAUDE.md carries
that as a recorded decision. Verified after eight waves of change:

- Actual Hours exists in Closeout (wp_actual_hrs), persists through collect,
  and prints on the export
- it is OPTIONAL: a package closes with it empty (driven, not assumed)
- it rolls up per T6.4: rollup_check has pinned actual-hours aggregation at
  every level since wave 6, and /api/wps/metrics carries actual_hours in its
  buckets

The follow-up the done-when requires is logged as BL-023: a productivity
factor (actual / estimated) - the rollup endpoints already carry both sums,
so it is a presentation task awaiting its own item id and a placement call.

Verification: export_check.py extended to 20/20 (the CR-017 section).

Items: CR-017

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

180 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""Does the export print what the field needs, and nothing it does not? — CR-008, T9.1.
The export was never walked through in the meeting. This is the walk: every
required field present, CR-006-suppressed sections absent, the two CR-002
fields (ACU Cost Code, Acumatica Task) appearing NOWHERE under the Micron
configuration, status carried by words (a black-and-white print keeps its
meaning), and the whole document legible on a tablet.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.5):
time.sleep(seconds)
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
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-export-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = 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)
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440, 900)
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
chk("the creator boots", wait_creator(page))
settle(1.6)
page.eval("window.alert=()=>{}; window.confirm=()=>false; window.prompt=()=>null;")
# The Micron configuration and its example package - the real shape.
page.eval("loadSampleSOP()")
settle(1.0)
page.eval("loadExample()")
settle(1.0)
doc = page.eval("(() => { renderPackage(collectPackage()); "
"return document.getElementById('pkg-doc').innerHTML; })()")
text = page.eval("(document.getElementById('pkg-doc')||{textContent:''}).textContent")
print("\n1. every listed field, present")
for label, needle in [
("P6 Activity id + description", "P6 Activity"),
("Priority", "Priority"),
("scope of work", "Scope & Work"),
("materials", "Material List"),
("constraints", "Constraints — Release Readiness"),
("QA/QC", "Quality & Hold Points"),
]:
chk("the export carries %s" % label, needle in text or needle in doc, needle)
chk("...and the location row (Building / Floor / Sector is a row of "
"General Information)", "Location" in text)
chk("constraint status prints as WORDS - a black-and-white print keeps "
"its meaning", "Cleared" in text and "Open" in text)
print("\n2. what must be absent, absent")
chk("ACU Cost Code appears nowhere", "Cost Code" not in text)
chk("Acumatica Task appears nowhere", "Acumatica Task" not in text)
chk("the toggled-off sections are absent (assets, kitting - the Micron "
"sample's CR-006 state)",
"Kitting & MIMO" not in text and "Asset" not in text.replace("Assets is off", ""))
print("\n3. the attachment index")
chk("every attachment is listed", "Drawings & Attachments" in text)
chk("...uploads would carry their descriptions (the columns exist)",
"Focus area" in text or "Link / Note" in text)
print("\n4. the tablet")
page.viewport(390, 844, mobile=True)
settle(0.8)
page.eval("renderPackage(collectPackage())")
settle(0.8)
# scrollWidth vs clientWidth is the wrong ruler for a padded overflow-y
# container: Chrome reports scrollWidth as content+padding even with
# nothing protruding, so the naive comparison is off by exactly the
# padding+border. What legibility actually needs: the BOX fits the
# screen, and no element inside pokes past the box.
fits = page.eval("""(() => {
const d = document.getElementById('pkg-doc');
const dr = d.getBoundingClientRect();
const cs = getComputedStyle(d);
let poke = 0;
d.querySelectorAll('*').forEach(el => {
const r = el.getBoundingClientRect();
if (r.right > dr.right + 2) poke++;
});
return {boxFits: dr.width <= 390, poke, font: parseFloat(cs.fontSize)};
})()""")
chk("390px: the document fits the screen and nothing inside pokes past it",
fits["boxFits"] and fits["poke"] == 0, ascii_(fits))
chk("390px: the base type is readable (>= 12px)", fits["font"] >= 12, ascii_(fits))
# ── 5. CR-017: the Actual Hours guard (T9.2) ─────────────────────────
# A guard, not a build: removal was floated and REJECTED - Marlena
# tracks actual hours. Verify it survived eight waves of change.
print(chr(10) + "5. CR-017: Actual Hours survived")
page.viewport(1440, 900)
settle(0.5)
chk("Actual Hours still exists in Closeout and persists",
page.eval("!!document.getElementById('wp_actual_hrs')")
and page.eval("collectPackage().actualHrs") == "54") # the example's value
page.eval("document.getElementById('wp_actual_hrs').value=''")
page.eval("""(() => { pkgConstraints.forEach(c => c.status='na');
buildConstraints(); updateReleaseBanner(); })()""")
page.eval("document.querySelector('#status-group .radio-pill[data-val=%s]').click()"
% json.dumps("Closed"))
settle(0.5)
chk("...it is OPTIONAL: a package closes without it",
page.eval("getRadio('status')") == "Closed",
page.eval("getRadio('status')"))
chk("...and the closeout prints it on the export",
"Actual" in page.eval("(() => { renderPackage(collectPackage()); "
"return document.getElementById('pkg-doc').textContent; })()"))
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors anywhere in this run", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())