diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 33c6ed7..55e1aa1 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -44,6 +44,13 @@ const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed']; const ISSUED_IDX = STATUS_ORDER.indexOf('Issued'); // Acumatica cost codes (comment 10) — code|description +// CR-003. Exactly three, in escalating order — the order is the sort order, and +// it is why priority does not sort alphabetically (High, Normal, Urgent would put +// the most urgent last). Do not add a fourth: the meeting settled on three, and a +// fourth level is how a priority scale stops meaning anything. +const WP_PRIORITIES = ['Normal', 'High', 'Urgent']; +const WP_PRIORITY_DEFAULT = 'Normal'; + const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract']; // Acumatica allowed units of measure (comment 15) — common first const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN']; @@ -1123,6 +1130,10 @@ function collectPackage(){ parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined, number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'), type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'), + p6Id:gv('wp_p6_id'), p6Desc:gv('wp_p6_desc'), // CR-001 + priority:wpPriorityOf({priority: gv('wp_priority')}), // CR-003 + + cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'), // Account ids behind those names — notification routing needs an account; @@ -1229,7 +1240,9 @@ function renderPackage(pkg){ ${fieldOn('acumaticaTask')?`Acumatica Task${cell(pkg.wbs)}`:''} Assignees${cell(pkg.assignees)} Distribution${cell(pkg.distribution)} - Due Date${cell(pkg.due)} + Priority${esc(wpPriorityOf(pkg))} + Due Date${cell(pkg.due)}${pkg.p6Id?` · P6 activity ${esc(pkg.p6Id)}`:''} + P6 Activity${pkg.p6Id?esc(pkg.p6Id)+(pkg.p6Desc?' — '+esc(pkg.p6Desc):''):ns()} Specification Section${cell(pkg.spec)} Description${cell(pkg.desc)} ${plinks.length?`Project Systems${plinks.map(l=>esc(l.label)+': '+linkify(l.url)).join('
')}`:''} @@ -1829,6 +1842,8 @@ function loadPackageIntoForm(p){ pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location); + set('wp_p6_id',p.p6Id); set('wp_p6_desc',p.p6Desc); // CR-001 + set('wp_priority', wpPriorityOf(p)); // CR-003 set('wp_assignees',p.assignees); set('wp_distribution',p.distribution); loadPeopleFromPkg(p); set('wp_assignee',p.assigneeId); @@ -1914,8 +1929,9 @@ function newPackage(){ // to return to. if(typeof WPUrl !== 'undefined' && window.wpCreatorReady && WPUrl.get('wp')) urlSyncPackage('', {replace:true}); editingId=null; - ['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); + ['wp_subject','wp_system','wp_location','wp_p6_id','wp_p6_desc','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value=''; + const prio=document.getElementById('wp_priority'); if(prio) prio.value=WP_PRIORITY_DEFAULT; // CR-003 ['wp_iff','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); onClashChange(); pkgKind='iwp'; applyKind(); @@ -1957,7 +1973,7 @@ const WPData = { if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; }, }; -let dashFilter={status:'',discipline:'',q:'',flag:''}; +let dashFilter={status:'',discipline:'',q:'',flag:'',priority:''}; // A write has to reach the server before the server can count it. The outbox is // the only path writes take, so flush it and then re-read - otherwise the refresh // races the push and shows the pre-write totals, which is the same stale number @@ -1979,7 +1995,7 @@ function dashRefreshAfterWrite(){ const DASH_FLAGS = ['ready', 'onhold', 'overdue', 'mine']; function dashToggleFlag(f, opts){ - if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; } + if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:'',priority:''}; } else { dashFilter.flag = dashFilter.flag===f ? '' : f; } dashPage=0; // S3: which slice of the board you are looking at is state, so it belongs in @@ -2009,6 +2025,84 @@ function dashApplyFlag(f){ } function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); } +// ── Board sorting (CR-001 / T6.1) ──────────────────────────────────────────── +// The board had no sorting at all. CR-001 asks for a sortable P6 activity column +// and CR-003 asks for a sortable priority one, so the mechanism is built once and +// declared as data — a column added later is a row in this table, not another +// hand-written and another comparator. +// +// EMPTIES SORT LAST, in both directions. That is the whole of "sorts correctly, +// including with empty values": 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. +const DASH_COLUMNS = [ + {key:'number', label:'WP #', get:p => p.number || ''}, + {key:'subject', label:'Subject', get:p => p.subject || ''}, + {key:'type', label:'Type', get:p => p.type || ''}, + {key:'disciplines', label:'Discipline', get:p => (p.disciplines || []).join(', ')}, + {key:'p6Id', label:'P6 activity',get:p => p.p6Id || ''}, + // Sorted by the ESCALATION order, not alphabetically: High, Normal, Urgent + // would put the most urgent last, which is the one thing the column is for. + {key:'priority', label:'Priority', numeric:true, + get:p => WP_PRIORITIES.indexOf(wpPriorityOf(p))}, + {key:'status', label:'Status', get:p => p.status || ''}, + {key:'gates', label:'Gates', sortable:false}, + {key:'due', label:'Due', get:p => p.due || ''}, + {key:'hours', label:'Hrs', numeric:true, get:p => p.hours}, + {key:'actions', label:'', sortable:false}, +]; + +let dashSort = {key:'', dir:1}; + +function dashSetSort(key){ + const col = DASH_COLUMNS.find(c => c.key === key); + if(!col || col.sortable === false) return; + dashSort = (dashSort.key === key) ? {key: key, dir: -dashSort.dir} : {key: key, dir: 1}; + dashPage = 0; + renderDashboard(); +} + +function dashSortRows(rows){ + const col = DASH_COLUMNS.find(c => c.key === dashSort.key); + if(!col || col.sortable === false) return rows; + const blank = v => v === null || v === undefined || String(v).trim() === ''; + // A copy: `rows` is derived from the live list and sorting it in place would + // reorder the board's own source. + return rows.slice().sort((a, b) => { + const va = col.get(a), vb = col.get(b); + const ea = blank(va), eb = blank(vb); + if(ea && eb) return 0; + if(ea) return 1; // empties last, whichever way the arrow points + if(eb) return -1; + if(col.numeric){ + const na = parseFloat(va), nb = parseFloat(vb); + // A non-numeric value in a numeric column is not an empty and is not a + // number either; it sorts after the numbers rather than as NaN, which + // compares false against everything and leaves the order undefined. + if(isNaN(na) && isNaN(nb)) return 0; + if(isNaN(na)) return 1; + if(isNaN(nb)) return -1; + return (na - nb) * dashSort.dir; + } + return String(va).localeCompare(String(vb), undefined, {numeric:true, sensitivity:'base'}) + * dashSort.dir; + }); +} + +function dashHeaderCells(){ + return DASH_COLUMNS.map(c => { + if(c.sortable === false) return `${esc(c.label)}`; + const on = dashSort.key === c.key; + const aria = on ? (dashSort.dir > 0 ? 'ascending' : 'descending') : 'none'; + const arrow = on ? (dashSort.dir > 0 ? ' ▲' : ' ▼') : ''; + // A real button inside the th, so it is in the tab order and Enter/Space work + // for free (C1). The direction is said by aria-sort as well as by the arrow. + return ``; + }).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())