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>
180 lines
7.3 KiB
Python
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())
|