diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index 96d53e1..edb6f5c 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -239,7 +239,8 @@ python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard opera python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks python tests/pipeline_check.py # B4 surface — server counts, shareable links 43 checks python tests/locations_check.py # CR-005 — codes not labels, nothing deleted 58 checks -python tests/sections_check.py # CR-006 — off means hidden, never deleted 53 checks +python tests/sections_check.py # CR-006/CR-002/CR-016 — hidden, not deleted 88 checks +python tests/validation_check.py # S1 wizard — errors at the field, no dialogs 81 checks ``` `pipeline_check.py` reads the dashboard's state out of the **iframe's DOM**, not its @@ -283,7 +284,8 @@ one. | — | `outline: none` in stylesheets | 6 | **1**, with its replacement one rule above | `T3.4`, `T4.7` | | — | helper-text contrast, worst case | 3.01:1 | **4.56:1** | `T4.6` | | 3 | `
` | 12 | **2** | `T5.1` | -| 1 | native dialogs app-wide | 79 | **77** | `T5.1` (2), `T5.2` (1), wave 4 (+1) | +| 1 | native dialogs app-wide | 79 | **64** | `T5.1` (2), `T5.2` (1), `T5.8` (13) | +| 2 | …of those, in the creator | 43 | **43** | unchanged — wave 7/9 | Metrics 2, 4 and 6 (creator dialogs, ``, `.help-tip` badges) are wave 9's to move and are unchanged. @@ -293,6 +295,10 @@ move and are unchanged. are wave 7 / wave 9 work. Nothing in the SOP wizard's markup carries a click handler on a non-interactive element any more. +**Metric 1 after `T5.8`:** the SOP wizard has **none**, measured both raw and with +comments stripped. What is left is `wp-creation-app.js` 43, `users.js` 10, `admin.js` 6 and +`index.html` 5 — the creator's are wave 7's, the rest wave 9's. + **Metric 1 is noisier than it looks** — the command matches `alert(` inside a *comment* as readily as inside code, and waves 3 and 4 left several comments explaining dialogs they were removing. That is why the figure was 80 before `T5.1` rather than the 79 wave 0 diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index c71de16..99bd546 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -388,7 +388,8 @@ function loadSampleData(){ if(currentTool && currentTool !== 'sop'){ const f = document.getElementById('wp-frame'); if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); } - else { alert('Open the Work Package Creation tab first, then load the sample.'); } + else { wizardToast('Open the Work Package Creation tab first, then load the sample.', + {role: 'alert'}); } return; } // Populate Step 1 @@ -454,7 +455,7 @@ function loadSampleData(){ collectStepData(); track('sample_loaded'); - alert('✓ Sample data loaded!\n\nNavigate through the SOP steps to see example values. You can edit or replace any field.'); + wizardToast('Sample data loaded. Every step now holds example values — edit or replace any of them.'); // Switch to step 1. Every step now holds sample content, so the rail marks them // all as visited — otherwise a fully populated wizard shows ten unstarted steps. @@ -1108,7 +1109,10 @@ function addCustomConstraintText(){ const inp = document.getElementById('custom-constraint-input'); const name = (inp && inp.value || '').trim(); if(!name){ if(inp) inp.focus(); return; } - if(state.constraints.find(c=>c.name===name)){ alert('That constraint is already in the list.'); return; } + if(state.constraints.find(c=>c.name===name)){ + wizardToast('“' + name + '” is already in the list.', {role: 'alert'}); + return; + } state.constraints.push({name, description:''}); if(inp) inp.value=''; renderStandardConstraints(); @@ -1528,6 +1532,57 @@ document.addEventListener('DOMContentLoaded', function(){ } }); +// ── ANNOUNCEMENTS (S1 / T5.8) ──────────────────────────────────────────────── +// Everything the wizard used to say in a native dialog says it here. +// +// A dialog is not merely ugly. It blocks the page until dismissed, it cannot be +// styled or placed, a screen reader can only present it as a modal interruption, +// and it is one OK button whatever it is telling you — so "sample data loaded" +// and "you cannot do that" arrive identically. This is the same split T4.5 +// established everywhere else: an error interrupts, a confirmation does not. +let _toastTimer = null; + +function wizardToast(message, opts){ + opts = opts || {}; + const el = document.getElementById('wp-toast'); + if(!el) return; + clearTimeout(_toastTimer); + el.hidden = false; + el.setAttribute('role', opts.role === 'alert' ? 'alert' : 'status'); + el.className = 'wp-toast' + (opts.role === 'alert' ? ' is-alert' : ''); + el.textContent = ''; + const text = document.createElement('span'); + text.className = 'wp-toast-text'; + text.textContent = message; + el.appendChild(text); + if(opts.action && opts.action.label){ + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'wp-toast-action'; + b.textContent = opts.action.label; + b.addEventListener('click', function(){ hideWizardToast(); opts.action.fn(); }); + el.appendChild(b); + } + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'wp-toast-close'; + close.setAttribute('aria-label', 'Dismiss this message'); + close.textContent = '\u2715'; + close.addEventListener('click', hideWizardToast); + el.appendChild(close); + // An error stays until dismissed. A confirmation does not need to be read + // twice, and one that lingers becomes furniture. + if(opts.role !== 'alert' && !opts.action){ + _toastTimer = setTimeout(hideWizardToast, 6000); + } +} + +function hideWizardToast(){ + clearTimeout(_toastTimer); + const el = document.getElementById('wp-toast'); + if(el){ el.hidden = true; el.textContent = ''; } +} + // ── STEP 12: WORK PACKAGE SECTIONS (CR-006) ─────────────────────────────────── // Which sections of a work package this project uses. The list itself lives in // wp-sections.js, shared with the creator so the two cannot disagree about what @@ -1651,13 +1706,45 @@ function pushSectionsToCreator(){ // T5.8 widens this to every step whose markup marks a field required, and replaces // the native dialog with inline errors. The shape is chosen so that is an edit to this // table rather than to the four functions below it. +// T5.8 widened this from three steps to every step whose markup marks a field +// required — which was the S1 defect: the markup marked fields throughout and +// validateStep() guarded 1, 5 and 6. Steps 3 and 7 marked required fields with an +// asterisk that meant nothing. +// +// The message is per FIELD now, not per step. "Subject and WP Type are required" +// named no field, highlighted nothing and scrolled nowhere; each of these names +// the one field it is about, renders at that field, and says what to do. const STEP_GATES = { - 1: {fields: ['proj_name', 'proj_number', 'proj_client', 'proj_division', 'proj_site'], - msg: 'Please complete all required fields: Project Name, Number, Client, Division, and Site Location.'}, - 5: {fields: ['gov_woformat'], msg: 'Please enter a Work Package Number Format.'}, - 6: {fields: ['qual_qcreq'], msg: 'Please select a QC requirement.'} + 1: {fields: [ + ['proj_name', 'Enter the project name.'], + ['proj_number', 'Enter the project number.'], + ['proj_client', 'Enter the client name.'], + ['proj_division', 'Enter the division or sector.'], + ['proj_site', 'Enter the site location.'], + ]}, + 3: {fields: [ + ['role_super_title', 'Name the first required sign-off role — rename it if “Superintendent” is not what this project calls it.'], + ['role_foreman_title', 'Name the second required sign-off role — rename it if “Foreman” is not what this project calls it.'], + ]}, + 5: {fields: [ + ['gov_woformat', 'Enter a work package number format, e.g. WP##-[Sector]-[TYPE].'], + ['gov_discmode', 'Choose how work packages use disciplines.'], + ]}, + 6: {fields: [ + ['qual_qcreq', 'Choose whether QC is required on this project.'], + ]}, + 7: {fields: [ + ['plat_tracking', 'Choose the construction tracking platform.'], + ['plat_commissioning', 'Choose the commissioning tool.'], + ]}, }; +// Just the ids, for the places that only care which fields a step gates on. +function stepGateFields(n){ + const gate = STEP_GATES[n]; + return gate ? gate.fields.map(f => f[0]) : []; +} + const STEP_LABELS = {1:'Project', 2:'Team', 3:'Sign-offs', 4:'WP types', 5:'Governance', 6:'Quality', 7:'Platforms', 8:'Sequence', 9:'Constraints', 10:'Sources', 11:'Locations', 12:'Sections'}; @@ -1672,13 +1759,13 @@ const LAST_STEP = 12; // unsaved change and fire T4.3's unsaved-work guard on the way out. const _visitedSteps = new Set([1]); +function fieldFilled(id){ + const el = document.getElementById(id); + return !!(el && String(el.value || '').trim()); +} + function stepGateMet(n){ - const gate = STEP_GATES[n]; - if(!gate) return true; - return gate.fields.every(id => { - const el = document.getElementById(id); - return !!(el && String(el.value || '').trim()); - }); + return stepGateFields(n).every(fieldFilled); } // What the rail may offer, stated as the guard already behaved rather than as @@ -1700,9 +1787,10 @@ function stepReachable(n){ function goToStep(n, opts){ const fromUrl = !!(opts && opts.fromUrl); // Restoring a step from the URL is not a forward navigation, so it must not run - // the forward-navigation guard. validateStep() ends in alert() when a required - // field is empty, which on a freshly-loaded deep link is ALWAYS - so a shared - // link to step 3 opened a modal dialog before the page had finished booting. + // the forward-navigation guard. validateStep marks every empty required field + // and focuses the first, which on a freshly-loaded deep link would mean landing + // on a step already painted red before you had typed anything. (Before T5.8 it + // was worse: a native dialog opened before the page had finished booting.) // // Neither is going BACKWARDS. previousStep() has never validated, so a rail that // did would trap you on an incomplete step with no way out but the Back button — @@ -1815,7 +1903,7 @@ function railBlockedClick(n){ const gate = STEP_GATES[currentStep]; railMessage(`Step ${n}, ${STEP_LABELS[n]}, is not available yet — finish step ${currentStep}, ${STEP_LABELS[currentStep]}, first.`); if(!gate) return; - const missing = gate.fields + const missing = stepGateFields(currentStep) .map(id => document.getElementById(id)) .find(el => el && !String(el.value || '').trim()); if(missing){ @@ -1842,13 +1930,16 @@ function collapseRailIfNarrow(){ // which is worse than the strip it replaced, because that one at least lied // consistently. const _GATE_FIELD_IDS = new Set( - Object.keys(STEP_GATES).reduce((all, n) => all.concat(STEP_GATES[n].fields), [])); + Object.keys(STEP_GATES).reduce((all, n) => all.concat(stepGateFields(n)), [])); function railWatchField(e){ const t = e.target; if(!t || !t.id || !_GATE_FIELD_IDS.has(t.id)) return; - // The refusal message is about a state that no longer holds once the field it - // named has been filled. + // An error message is about a state that no longer holds once the field it + // named has been filled. Cleared as you type rather than on the next submit — + // an error still showing over a field you have just corrected teaches people + // to ignore errors. + if(fieldFilled(t.id)) setFieldError(t.id, ''); if(stepGateMet(currentStep)) railMessage(''); renderStepRail(); } @@ -1856,6 +1947,7 @@ function railWatchField(e){ document.addEventListener('DOMContentLoaded', function(){ const list = document.getElementById('step-rail-list'); const toggle = document.getElementById('step-rail-toggle'); + ensureErrorBoxes(); document.addEventListener('input', railWatchField); document.addEventListener('change', railWatchField); if(toggle){ @@ -1964,16 +2056,88 @@ function collectStepData(){ } } -// Same three gates, same three messages, read from STEP_GATES so the rail and the -// guard can never disagree about which steps are open. T5.8 replaces the dialog -// here with inline field errors; the table above is what it will validate against. +// ── INLINE VALIDATION (S1 wizard half / T5.8) ──────────────────────────────── +// Was one native dialog per step, naming no field. Now: an error at each field +// that needs one, associated with it through aria-describedby, announced through +// its own live region, and the first one focused and scrolled to. +// +// The error elements are BUILT from STEP_GATES rather than written into the +// markup twelve times. That is not laziness — it is what makes the table the +// single source: adding a required field is one row here, and its error element, +// its aria-describedby and its announcement all follow. A markup-side error box +// that somebody forgets to add is an error nobody ever sees. +function errBoxId(fieldId){ return fieldId + '_err'; } + +function ensureErrorBoxes(){ + Object.keys(STEP_GATES).forEach(n => { + STEP_GATES[n].fields.forEach(([id]) => { + const el = document.getElementById(id); + if(!el || document.getElementById(errBoxId(id))) return; + const box = document.createElement('div'); + box.className = 'field-error'; + box.id = errBoxId(id); + // role="alert" so a validation failure interrupts. T4.5's rule: an error is + // the case where waiting for a pause is too late, because the person is + // already trying to leave the step. + box.setAttribute('role', 'alert'); + (el.parentNode || document.body).insertBefore(box, el.nextSibling); + // Appended rather than assigned: several of these fields already point at a + // hint, and clobbering it would trade one message for another. + const prior = (el.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean); + if(prior.indexOf(box.id) < 0) prior.push(box.id); + el.setAttribute('aria-describedby', prior.join(' ')); + }); + }); +} + +function setFieldError(fieldId, message){ + const el = document.getElementById(fieldId); + const box = document.getElementById(errBoxId(fieldId)); + if(box) box.textContent = message || ''; + if(el){ + if(message) el.setAttribute('aria-invalid', 'true'); + else el.removeAttribute('aria-invalid'); + } +} + +// For a field that is not part of a step gate but still needs to say something +// at itself — the feedback textarea. Its error box is in the markup because it is +// one field rather than a table of them. +function setSimpleFieldError(fieldId, message){ + const el = document.getElementById(fieldId); + const box = document.getElementById(fieldId + '_err'); + if(box) box.textContent = message || ''; + if(el){ + if(message) el.setAttribute('aria-invalid', 'true'); + else el.removeAttribute('aria-invalid'); + } +} + +function clearStepErrors(n){ + stepGateFields(n).forEach(id => setFieldError(id, '')); +} + +/* Validate one step, marking every field that needs marking and focusing the + first. Nothing here paints a step you have not reached: the rail asks + stepGateMet(), which reads the same fields and marks nothing. Painting nine + steps red on arrival is not validation, it is noise. */ function validateStep(n){ collectStepData(); - if(!stepGateMet(n)){ - alert(STEP_GATES[n].msg); - return false; + const gate = STEP_GATES[n]; + if(!gate) return true; + let first = null; + gate.fields.forEach(([id, msg]) => { + const bad = !fieldFilled(id); + if(bad && !first) first = id; + setFieldError(id, bad ? msg : ''); + }); + if(!first) return true; + const el = document.getElementById(first); + if(el){ + try { el.scrollIntoView({block: 'center', behavior: 'smooth'}); } catch(e) { } + el.focus(); } - return true; + return false; } // ── SOP COMPLETION ──────────────────────────────────────────────────────────── @@ -1986,8 +2150,9 @@ function canEditCompletedSOP(){ function completeSOP(){ if(sopComplete && !canEditCompletedSOP()){ - alert('This project\'s SOP is already complete, and changing it needs the Project Admin role.\n\n' + - 'Ask a project admin to make the change — the SOP is the baseline every work package inherits.'); + wizardToast('This project\'s SOP is already complete, and changing it needs the Project Admin ' + + 'role. Ask a project admin — the SOP is the baseline every work package inherits.', + {role: 'alert'}); return; } if(!validateStep(LAST_STEP)) return; @@ -2096,7 +2261,7 @@ function completeSOP(){ // the user stays), then return to the project home page per the requested flow. if(typeof onSOPReady === 'function') onSOPReady(sop); - alert('✓ SOP Configuration Complete!\n\nReturning to the project home page.'); + wizardToast('SOP configuration complete. Returning to the project home page.'); window.location.href = 'index.html'; } @@ -2120,7 +2285,13 @@ function submitComment(){ const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous'; const text = document.getElementById('comment-text').value.trim(); - if(!text){ alert('Please enter a comment.'); return; } + if(!text){ + // At the field, like every other validation message on this page now. + setSimpleFieldError('comment-text', 'Enter some feedback before submitting.'); + document.getElementById('comment-text')?.focus(); + return; + } + setSimpleFieldError('comment-text', ''); const comment = { step: currentStep, @@ -2140,7 +2311,7 @@ function submitComment(){ function exportComments(){ const saved = localStorage.getItem('wp_suite_comments'); const data = saved ? JSON.parse(saved) : []; - if(!data.length){ alert('No comments to export yet.'); return; } + if(!data.length){ wizardToast('There is no feedback to export yet.', {role: 'alert'}); return; } const payload = {app:'Work Package Suite', source:'sop', exportedAt:new Date().toISOString(), comments:data}; const blob = new Blob([JSON.stringify(payload,null,2)], {type:'application/json'}); const a = document.createElement('a'); @@ -2158,7 +2329,7 @@ function importComments(ev){ try{ const inc = JSON.parse(r.result); const incoming = Array.isArray(inc) ? inc : (inc.comments || []); - if(!incoming.length){ alert('No comments found in that file.'); return; } + if(!incoming.length){ wizardToast('No feedback found in that file.', {role: 'alert'}); return; } const saved = localStorage.getItem('wp_suite_comments'); allComments = saved ? JSON.parse(saved) : []; const seen = new Set(allComments.map(c=>c.step+'|'+c.timestamp+'|'+c.text)); @@ -2166,8 +2337,8 @@ function importComments(ev){ incoming.forEach(c=>{ const k=c.step+'|'+c.timestamp+'|'+c.text; if(c.text && !seen.has(k)){ allComments.push(c); seen.add(k); added++; }}); localStorage.setItem('wp_suite_comments', JSON.stringify(allComments)); loadStepComments(); - alert('Imported ' + added + ' comment' + (added===1?'':'s') + '.'); - }catch(e){ alert('Could not read that file.'); } + wizardToast('Imported ' + added + ' feedback item' + (added===1?'':'s') + '.'); + }catch(e){ wizardToast('Could not read that file.', {role: 'alert'}); } ev.target.value = ''; }; r.readAsText(f); @@ -2240,8 +2411,17 @@ function showAnalytics(){ txt += `\nActions:\n`; Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`); txt += ` field edits: ${s.byEvent['field_edit']||0}\n`; - txt += `\nDownload full event log as JSON?`; - if(confirm(txt)) downloadAnalytics(); + // Was a native confirmation carrying the whole summary as its body — a wall of + // text in a dialog with one OK button. The summary is the useful part, so it is + // shown, and the download is offered as an action beside it rather than as the + // only way to dismiss the message. + // + // Note this function has no caller in the wizard's markup: the "Usage data" + // button lives on the creator, which has its own showAnalytics(). Converted + // rather than deleted because deleting a feature is not what T5.8 was asked to + // do, and its dialog counts toward the number this task has to drive to zero. + wizardToast(txt.replace(/\n+/g, ' · ').trim(), + {action: {label: 'Download the full event log', fn: downloadAnalytics}}); } function downloadAnalytics(){ const data = analyticsLoad(); diff --git a/html/work-package-suite-styles.css b/html/work-package-suite-styles.css index 15047a4..b95a42e 100644 --- a/html/work-package-suite-styles.css +++ b/html/work-package-suite-styles.css @@ -754,6 +754,51 @@ body.embed-full { overflow: hidden; } .field-toggle.is-off { border-left-color: var(--border-strong); } .field-toggle.is-off .section-toggle-name { color: var(--text-light); } +/* ══ ANNOUNCEMENTS — S1 / T5.8 ════════════════════════════════════════════════ + Where the wizard's thirteen native dialogs went. Fixed to the bottom of the + viewport so it is visible from any scroll position on a step that is three + screens tall, and never over the step navigation, which is where somebody's + attention is when a validation message fires. */ +.wp-toast { + position: fixed; + left: 50%; + bottom: var(--wp-s5); + transform: translateX(-50%); + z-index: 1400; /* over the drawer scrim, under a modal */ + display: flex; + align-items: flex-start; + gap: var(--wp-s3); + max-width: min(640px, calc(100vw - 2 * var(--wp-s4))); + padding: var(--wp-s3) var(--wp-s4); + background: var(--cds-ui-05); + color: var(--cds-text-on-color); + box-shadow: var(--wp-shadow-toast); + font-size: 13px; + line-height: 1.5; +} +.wp-toast[hidden] { display: none; } +.wp-toast-text { flex: 1 1 auto; min-width: 0; } +/* An error looks different, not just louder: a red rule down its edge, and it + stays until dismissed where a confirmation times out (C1 — never colour + alone, and the persistence is the second channel). */ +.wp-toast.is-alert { border-left: 4px solid var(--danger); } +.wp-toast-action, +.wp-toast-close { + flex: none; + background: none; + border: 1px solid var(--wp-appbar-border); + border-radius: 0; + color: var(--cds-text-on-color); + font: inherit; + font-size: 12px; + font-weight: 600; + padding: var(--wp-s1) var(--wp-s2); + cursor: pointer; +} +.wp-toast-close { border-color: transparent; padding: var(--wp-s1); } +.wp-toast-action:hover, +.wp-toast-close:hover { background: var(--wp-appbar-hover); } + /* .field-error is declared once, in theme-light.css — the launcher's create form and T5.8's step validation use the same component. */ diff --git a/html/work-package-suite.html b/html/work-package-suite.html index bd0283f..904776c 100644 --- a/html/work-package-suite.html +++ b/html/work-package-suite.html @@ -513,8 +513,9 @@
- - + + +
@@ -542,6 +543,13 @@
+ + + diff --git a/tests/stepper_check.py b/tests/stepper_check.py index 1f0cca8..a2660b1 100644 --- a/tests/stepper_check.py +++ b/tests/stepper_check.py @@ -343,13 +343,14 @@ def run(page, base, tok): [r["step"] for r in rows if r["ariaDisabled"] == "true"]) chk("...and the guard refuses the same move", page.eval("(() => { const n = currentStep; goToStep(4); return currentStep === n; })()")) - # That call went through validateStep() directly rather than through the rail, - # so it hit the wizard's surviving alert. Recorded here so the fact is on the - # page rather than hidden, and cleared so check 8 below still means something. + # Until T5.8 this call raised the wizard's surviving alert, and this check + # recorded that rather than hiding it. T5.8 replaced it with an error at the + # field, so the same call now refuses silently and marks the field instead. guard_dialogs = json.loads(page.eval("JSON.stringify(window.__dialogs||[])")) - chk("...via the wizard's alert, which T5.8 still owns", - len(guard_dialogs) == 1 and guard_dialogs[0][0] == "alert", guard_dialogs) - page.eval("window.__dialogs = []") + chk("...with no dialog at all, since T5.8", not guard_dialogs, guard_dialogs) + chk("...and an error at the field that is in the way", + bool((page.eval("(document.getElementById('proj_site_err')||{}).textContent||''")).strip()), + page.eval("(document.getElementById('proj_site_err')||{}).textContent||''")) chk("...while going BACKWARDS is still allowed, so nobody is trapped", page.eval("(() => { currentStep = 4; updateStepUI(); goToStep(2); return currentStep; })()") == 2, page.eval("currentStep")) diff --git a/tests/validation_check.py b/tests/validation_check.py new file mode 100644 index 0000000..1a33e94 --- /dev/null +++ b/tests/validation_check.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""Inline validation in the SOP wizard — S1, wizard half (T5.8). + +The defect S1 records has two halves and they are easy to conflate. One is that +validation was a native dialog. The other is that it guarded three steps while +the markup marked required fields on five — so two steps' asterisks meant +nothing at all, which is worse than no asterisk. + + 1. every step with required fields validates them (1, 3, 5, 6, 7 — counted + from the markup, not from the old guard) + 2. each error renders at its field and is associated via aria-describedby + 3. submitting an invalid step focuses and scrolls to the first error + 4. errors announce to screen readers + 5. the wizard's native dialog count is 0 + +Check 5 is measured two ways, because the wave 0 metric counts the word +`alert(` inside a comment as readily as inside code (BL-017). Both figures are +recorded: raw, and with comments stripped. + +Every dialog is stubbed before anything is driven, so a survivor is caught and +reported rather than hanging the session. + +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 +""" + +# Read out of the markup, not out of the app's gate table: the S1 defect is +# precisely that the two disagreed, so a probe that read the table would agree +# with whatever the table says and prove nothing. +STEP_FIELDS = { + 1: ["proj_name", "proj_number", "proj_client", "proj_division", "proj_site"], + 3: ["role_super_title", "role_foreman_title"], + 5: ["gov_woformat", "gov_discmode"], + 6: ["qual_qcreq"], + 7: ["plat_tracking", "plat_commissioning"], +} +NO_REQUIRED = [2, 4, 8, 9, 10, 11, 12] + + +def settle(seconds=1.4): + time.sleep(seconds) + + +def ascii_(v): + """Windows consoles are cp1252, and a failure message carrying the toast's + close glyph crashes the reporter instead of reporting the failure. Losing the + glyph costs nothing; losing the diagnosis costs the run.""" + return str(v).encode("ascii", "replace").decode("ascii") + + +def open_wizard(page, base, tok, query="?project=projA"): + page.clear_cookies() + page.set_cookie("wp_session", tok["root"]) + page.goto(base + "/work-package-suite.html" + query) + settle(2.0) + page.eval(STUB) + + +def required_fields_in_markup(): + """Which steps mark a field required, straight from work-package-suite.html. + + Two notations: an asterisk in the