From ce2d008897b3598af58e43ea42278e13d95ddfc0 Mon Sep 17 00:00:00 2001 From: "n.siegfried" Date: Sun, 16 Aug 2026 14:21:57 -0500 Subject: [PATCH] T6.1/T6.2 - CR-001 and CR-003: the schedule driver and the urgency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fields and one board column mechanism, committed together because the second is only there for the first: the board had NO sorting at all, CR-001 asks for a sortable column, and CR-003 asks for another. Building the mechanism twice, or building it once and pretending the second task got it free, are both worse than saying so. CR-001 - P6 activity ID and description Every work package traces back to the schedule activity that drives it, so a date on a package is anchored rather than floating. Placed beside the due date, which is where the meeting put it and the reason it is there. Free text. A validated lookup against an imported activity list is deferred (BL-000a) partly because the Micron schedule is being reworked - importing it now would import churn. Both fields live inside General Information, so CR-006's toggle governs them without any further wiring. The probe checks that by turning the section off and reading the rendered document, rather than by asserting they are in the right
. CR-003 - Priority Three levels, agreed live in the meeting, and no fourth. Normal is the baseline default, and a package saved before today reads as Normal rather than blank - blank would sort and filter as an invisible fourth level. Sorted by ESCALATION, not alphabetically. High/Normal/Urgent would put the most urgent last, which is the one thing the column exists to prevent. The probe asserts the order AND that it is not the sorted order. Colour is never the only signal. The label is always rendered; the three differ by fill as well as by hue (outline / amber / red). Every value is a canonical token - X7's warning is that without one source of truth for colour, Normal/High/Urgent gets four implementations. 0 colour literals in the creator's stylesheet, asserted rather than assumed. Independent of status: the probe changes priority and checks the status radio did not move, then checks collectPackage reports the new priority with the old status. Sorting, and what "including with empty values" had to decide EMPTIES LAST, in both directions. Ascending by P6 activity means "the ones with an activity, in order, then the ones without", because nobody sorts by a column in order to look at the rows that have nothing in it. Reversing the direction reverses the filled rows and leaves the blanks where they are. The probe checks both directions and that no row is lost either way. A non-numeric value in a numeric column is neither empty nor a number; it sorts after the numbers rather than as NaN, which compares false against everything and leaves the order undefined. Every sortable header is a real `; + }).join(''); +} + // ── Phase 2: pagination, progress %, and archived view ────────────────────── let dashPage=0, dashShowArchived=false, dashArchived=[]; const DASH_PAGE_SIZE=25; @@ -2043,6 +2137,22 @@ function statusPill(s){ const label = s==='Issue' ? 'Issue (Hold)' : (s||'—'); return `${esc(label)}`; } +// A package saved before CR-003 has no priority at all, and so does one whose +// value has been hand-edited to something not on the list. Both mean Normal — +// the baseline — rather than blank, which would sort and filter as its own +// invisible fourth level. +function wpPriorityOf(p){ + const v = (p && p.priority) || ''; + return WP_PRIORITIES.indexOf(v) >= 0 ? v : WP_PRIORITY_DEFAULT; +} + +function priorityPill(p){ + const v = wpPriorityOf(p); + // The LABEL is always present. The colour is a second channel, never the only + // one (C1 / X7), and every one of them is a canonical token. + return `${esc(v)}`; +} + function myUserId(){ try { return (window.WP_USER && window.WP_USER.id) || ''; } catch(e){ return ''; } } function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); } // Predecessor packages of `p` that aren't Closed. Deleted ones don't block. @@ -2174,10 +2284,12 @@ function renderDashboard(){ const statusOpts=[''].concat(STATUS_ORDER.concat(['Issue']).map(s=>``)).join(''); const discList=Object.keys(byDisc); const discOpts=[''].concat(discList.map(d=>``)).join(''); + const prioOpts=[''].concat(WP_PRIORITIES.map(v=>``)).join(''); h+=`
+
`; @@ -2187,6 +2299,7 @@ function renderDashboard(){ const rows=boardSource.filter(p=>{ if(dashFilter.status && p.status!==dashFilter.status) return false; if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false; + if(dashFilter.priority && wpPriorityOf(p)!==dashFilter.priority) return false; if(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false; if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) return false; if(dashFilter.flag==='ready' && !(!p.split && !wpReleaseBlocked(p) && p.status!=='Closed' && p.status!=='Issue')) return false; @@ -2194,11 +2307,12 @@ function renderDashboard(){ if(dashFilter.flag==='overdue' && !isOverdue(p)) return false; return true; }); - const totalRows=rows.length; + const sorted=dashSortRows(rows); + const totalRows=sorted.length; const pages=Math.max(1, Math.ceil(totalRows/DASH_PAGE_SIZE)); if(dashPage>=pages) dashPage=pages-1; if(dashPage<0) dashPage=0; - const pageRows=rows.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE); + const pageRows=sorted.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE); // The board is a LIST, not a rollup: it renders the packages this browser holds, // which is what keeps the field view usable offline. Its header is therefore the // only count on this page not computed by the server — so it is reconciled against @@ -2211,8 +2325,8 @@ function renderDashboard(){ ? ` this browser has ${localCountable} of ${m.total}` : ''; h+=`
Work packages (${totalRows})${drift}
- `; - if(!totalRows) h+=``; +
WP #SubjectTypeDisciplineStatusGatesDueHrs
No work packages match.
${dashHeaderCells()}`; + if(!totalRows) h+=``; pageRows.forEach(p=>{ const ix=savedPackages.findIndex(x=>x.id===p.id); const open=wpOpenConstraints(p).length; @@ -2234,6 +2348,8 @@ function renderDashboard(){ h+=` + + `; }); diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index d5feb8b..2e6dee3 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -180,7 +180,26 @@
+ +
+
+ +
+
+
+
diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index b998f59..3967bb4 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -904,6 +904,41 @@ .dash-table { width:100%; border-collapse:collapse; font-size:12.5px; } .dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); } .dash-table td { border-bottom:1px solid var(--border); padding:6px 8px; vertical-align:top; } + /* CR-001 / T6.1: a sortable header is a real
No work packages match.
${esc(p.number||'—')}${p.instanceOf?` ${esc(p.instanceLabel||'')}`:''}${p.archived?' archived':''} ${esc(p.subject||'')}${esc(p.type||'')} ${esc((p.disciplines||[]).join(', '))||ns()}${cell(p.p6Id)}${priorityPill(p)} ${statusPill(p.status)}${gates}${due}${cell(p.hours)} ${actions}
, so it is + in the tab order and Enter and Space work without being wired up. It inherits + the header's own type rather than declaring its own, so the row still reads as + one strip. A th with no sort (Gates, the actions column) has no button and is + therefore not offered as one. */ + .dash-table th .dash-sort { + background:none; border:none; padding:0; margin:0; border-radius:0; + font:inherit; color:inherit; text-transform:inherit; letter-spacing:inherit; + cursor:pointer; display:inline-flex; align-items:center; gap:4px; + } + .dash-table th .dash-sort:hover { color:var(--accent); } + /* The sorted column is said three ways: bold, an arrow, and aria-sort on the + th — so it survives both "cannot see colour" and "cannot see the arrow". */ + .dash-table th .dash-sort.is-sorted { color:var(--text); font-weight:700; } + + /* CR-003 / X7: priority, coloured from canonical tokens only. X7's warning is + that without one source of truth for colour, Normal/High/Urgent gets four + implementations — so these alias the same status tokens the badges and + banners already use, and declare nothing. + + The LABEL is always rendered. Colour is a second channel and never the only + one: Normal is a plain outline, High is amber-filled and Urgent is + red-filled, so the three differ by weight and fill as well as by hue. */ + .prio { + display:inline-block; padding:1px 8px; border:1px solid var(--border-strong); + border-radius:20px; font-size:10px; font-weight:600; letter-spacing:.04em; + color:var(--text-muted); background:var(--surface); white-space:nowrap; + } + .prio-high { + color:var(--accent-amber); border-color:var(--wp-status-warning-border-a); + background:var(--accent-amber-dim); + } + .prio-urgent { + color:var(--wp-btn-danger-fill-fg); border-color:var(--red); background:var(--red); + } .dash-filters { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:14px; } .dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; } .dash-filters input[type=search] { flex:1; min-width:200px; } diff --git a/tests/generalinfo_check.py b/tests/generalinfo_check.py new file mode 100644 index 0000000..23fb33b --- /dev/null +++ b/tests/generalinfo_check.py @@ -0,0 +1,480 @@ +#!/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; + })()""")) + chk("...inside General Information, so the CR-006 toggle governs them", + page.eval("""(() => { + const card = document.getElementById('general-card'); + return card.contains(document.getElementById('wp_p6_id')) + && card.contains(document.getElementById('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())