diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index e157e8b..a3939b2 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -279,6 +279,7 @@ python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone? python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open section 51 checks python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks +python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks ``` **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index f95aec9..f922db1 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -2247,12 +2247,30 @@ function renderWpNav(){ : (open ? open + ' open' : (waiting ? 'waits on ' + waiting : 'ready')); const active = (editingId && p.id === editingId) ? ' active' : ''; const title = (p.number || '') + ' — ' + (p.subject || '') + ' · ' + (p.status || '') + ' · ' + state; + // A6: the stand-up answer, inline. Status, priority, due date, P6 activity + // and the open count all on the row; a held package says WHY it is held, + // from the newest live hold entry - the modal captured it, so display it. + // The title attribute above keeps the hover summary, but hover is never + // the only path (C1: Field View runs on tablets). + const triage = esc(p.status || 'Draft') + ' · ' + esc(wpPriorityOf(p)) + + ' · due ' + esc(p.due || '—') + ' · P6 ' + esc(p.p6Id || '—'); + let holdLine = ''; + if (p.status === 'Issue') { + const live = (p.holds || []).filter(h => h && !h.released); + const h = live[live.length - 1]; + const why = h ? (h.constraint ? h.constraint + ': ' : '') + (h.details || '') + : 'no reason recorded — log it from the status control'; + holdLine = '⛔ ' + (open ? open + ' open — ' : '') + + esc(why) + ''; + } return ''; diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index 5934fce..9a3a2fc 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -882,6 +882,18 @@ body { --nav-w: 288px; } body.wp-nav-collapsed { --nav-w: 56px; } + /* A6: the triage line and the inline hold reason. These WRAP - an ellipsis + here would hide exactly the data the row exists to show. The hold reason is + clamped at three lines so one essay of a reason cannot swallow the panel. */ + .wp-nav-item { align-items: flex-start; } + .wp-nav-item .wp-nav-badge { margin-top: 2px; } + .wp-nav-triage { display: block; font-size: 10.5px; line-height: 1.5; + color: var(--text-muted); white-space: normal; margin-top: 1px; } + .wp-nav-hold { display: -webkit-box; -webkit-line-clamp: 3; + -webkit-box-orient: vertical; overflow: hidden; + font-size: 10.5px; line-height: 1.45; color: var(--red); + white-space: normal; margin-top: 2px; } + /* -- collapse toggle -- */ .wp-nav-top { display: flex; align-items: center; padding: 8px 10px 2px; } .wp-nav-toggle { diff --git a/tests/triage_check.py b/tests/triage_check.py new file mode 100644 index 0000000..1ce1c8a --- /dev/null +++ b/tests/triage_check.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Can the sidebar answer the stand-up question? — A6, T7.5. + +The use case in the task: someone is asked in a stand-up why a package has not +moved. They open it on a phone and need the answer WITHOUT scrolling or +clicking — so the hold reason, captured by the hold modal since T7.3, has to be +readable straight off the navigator row, along with status, priority, due date, +P6 activity and the open-constraint count. + +Three package shapes cover the matrix: + A — on hold, reason recorded (the stand-up case) + B — a plain draft (no hold: the row must look sensible, not have empty slots) + C — on hold the legacy way, holds:[] (the row must say the reason is missing, + not render an empty red line) + +Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome. +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 + +REASON = "Boom lift recalled for inspection" + + +def ascii_(v, n=300): + return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n] + + +def settle(seconds=0.6): + 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 constraint_btn(page, index, which): + labels = {"open": "Open", "cleared": "Cleared", "na": "N/A"} + page.eval("""(() => { + const tr = document.querySelectorAll('#constraint-body tr')[%d]; + [...tr.querySelectorAll('.cstatus button')] + .find(b => b.textContent.trim() === %s).click(); + })()""" % (index, json.dumps(labels[which]))) + settle(0.4) + + +def click_status(page, val): + page.eval("document.querySelector('#status-group .radio-pill[data-val=%s]').click()" + % json.dumps(val)) + settle(0.4) + + +def set_field(page, fid, val): + page.eval("(() => { const el = document.getElementById(%s); el.value = %s; " + "el.dispatchEvent(new Event('input', {bubbles: true})); })()" + % (json.dumps(fid), json.dumps(val))) + + +def nav_row(page, number): + """The navigator row for a package number, as data.""" + return json.loads(page.eval("""JSON.stringify((() => { + const row = [...document.querySelectorAll('.wp-nav-item')] + .find(b => (b.querySelector('.wp-nav-num')||{textContent:''}).textContent === %s); + if (!row) return null; + const g = sel => { const e = row.querySelector(sel); return e ? e.textContent : null; }; + const r = row.getBoundingClientRect(); + return {triage: g('.wp-nav-triage'), hold: g('.wp-nav-hold'), + state: g('.wp-nav-state'), subj: g('.wp-nav-subj'), + h: r.height, scrollW: row.scrollWidth, clientW: row.clientWidth}; + })())""" % json.dumps(number))) + + +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-triage-") + 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.5) + page.eval("window.prompt=()=>null; window.alert=()=>{}; window.confirm=()=>false;") + + # ── build package A: on hold, with a reason ─────────────────────────── + print("\n1. package A: held, reason recorded (the stand-up case)") + set_field(page, "wp_subject", "Horn strobe conduit") + set_field(page, "wp_type", "Conduit Install") + set_field(page, "wp_priority", "High") + set_field(page, "wp_due", "2026-09-01") + set_field(page, "wp_p6_id", "A-1040") + n = page.eval("pkgConstraints.length") + for i in range(n): + constraint_btn(page, i, "cleared") + click_status(page, "In Progress") + constraint_btn(page, 0, "open") # opens the hold modal + page.eval("document.getElementById('hold-details').value=%s" % json.dumps(REASON)) + page.eval("submitHold()") + settle(0.4) + page.eval("savePackage(false)") + settle(0.8) + numA = page.eval("(savedPackages[savedPackages.length-1]||{}).number||''") + row = nav_row(page, numA) + chk("the held package renders a row in the sidebar", row is not None, numA) + chk("the hold reason is ON the row — no modal, no hover, no click", + row and REASON in (row["hold"] or ""), ascii_(row)) + chk("...and the row names the constraint it hangs on", + row and "Construction Equipment" in (row["hold"] or "") + or (row and (row["hold"] or "").count(":") >= 1), ascii_(row and row["hold"])) + chk("no modal is open while all of that is readable", + page.eval("!document.querySelector('#hold-modal.open')")) + tri = (row or {}).get("triage") or "" + chk("status, priority, due date and P6 activity are all on the row together", + "Issue" in tri and "High" in tri and "2026-09-01" in tri and "A-1040" in tri, + ascii_(tri)) + chk("...and the open-constraint count is on the row too", + row and re.search(r"1 open", (row["hold"] or "") + " " + (row["state"] or "")), + ascii_(row)) + + # ── package B: a plain draft ────────────────────────────────────────── + print("\n2. package B: nothing held, nothing weird") + page.eval("newPackage()") + settle(0.6) + set_field(page, "wp_subject", "Wire pull") + set_field(page, "wp_type", "Conduit Install") + page.eval("savePackage(false)") + settle(0.8) + numB = page.eval("(savedPackages[savedPackages.length-1]||{}).number||''") + row_b = nav_row(page, numB) + chk("the no-hold package has NO hold line at all — not an empty red slot", + row_b is not None and row_b["hold"] is None, ascii_(row_b)) + chk("...its state still says something (ready / N open), never a blank", + row_b and (row_b["state"] or "").strip() != "", ascii_(row_b)) + tri_b = (row_b or {}).get("triage") or "" + chk("...and its triage line shows placeholders, not gaps, for unset fields", + "Draft" in tri_b and "—" in tri_b and "Normal" in tri_b, ascii_(tri_b)) + + # ── package C: held the legacy way, holds:[] ────────────────────────── + print("\n3. package C: a legacy hold with no entry") + page.eval("""(() => { + const p = collectPackage(); + p.id = 'wp_legacy_probe'; p.number = 'WP99-LEGACY'; + p.subject = 'Legacy hold'; p.status = 'Issue'; p.holds = []; + p.constraints[0].status = 'open'; + savedPackages.push(p); renderWpNav(); + })()""") + settle(0.5) + row_c = nav_row(page, "WP99-LEGACY") + chk("a hold with no recorded entry says so instead of rendering nothing", + row_c and "no reason recorded" in (row_c["hold"] or ""), ascii_(row_c)) + + # ── 390px: the phone in the stand-up ────────────────────────────────── + print("\n4. 390px") + page.viewport(390, 900, mobile=True) + settle(0.8) + page.eval("openWpNav()") + settle(0.6) + row = nav_row(page, numA) + chk("390px: the held row renders in the opened panel", row is not None + and row["h"] > 0, ascii_(row)) + chk("390px: the reason is still readable there", row + and REASON in (row["hold"] or ""), ascii_(row)) + chk("390px: the row does not overflow its panel sideways", + row and row["scrollW"] <= row["clientW"] + 2, ascii_(row)) + chk("390px: the row is a comfortable tap target (>= 44px tall)", + row and row["h"] >= 44, ascii_(row)) + + js_errors = [e for e in page.js_errors()] + 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())