T5.8 - S1 (wizard): errors at the field, and the last dialog is gone
S1 has two halves and they are easy to conflate.
One is that validation was a native dialog: "Please complete all required
fields: Project Name, Number, Client, Division, and Site Location." names five
fields at once, highlights none of them, and scrolls nowhere. The other is that
validateStep guarded steps 1, 5 and 6 while the MARKUP marks required fields on
1, 3, 5, 6 and 7 - so two steps' asterisks meant nothing at all, which is worse
than no asterisk.
Both fixed, and the second is the one worth measuring: the probe reads the
required-field list out of work-package-suite.html rather than out of STEP_GATES,
because a probe that read the table would agree with whatever the table says and
prove nothing. Both notations count - an asterisk in a <label>, and the red span
beside step 3's role titles.
Now: an error per FIELD, rendered at it, associated by aria-describedby, marked
aria-invalid, announced through role="alert", and the first one focused and
scrolled into view. The error boxes are BUILT from the gate table rather than
written into the markup twelve times - adding a required field is one row, and
its error element, its association and its announcement all follow. A
markup-side error box somebody forgets to add is an error nobody ever sees.
An error clears 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.
And nothing paints a step you have not tried to leave: the rail asks
stepGateMet(), which reads the same fields and marks none of them.
The thirteen dialogs
Every one is now the thing it should have been - an error at the field it is
about, or an announcement in a live region with the role T4.5 established:
errors interrupt, confirmations do not.
A dialog is not merely ugly. It blocks the page, cannot be placed or styled, a
screen reader can present it only as a modal interruption, and it is one OK
button whatever it says - so "sample data loaded" and "you cannot do that"
arrived identically.
Two deserve naming. The empty-comment alert became an inline error on the
feedback textarea. And showAnalytics() was a confirm() carrying the entire
usage summary as its body - a wall of text in a dialog whose only dismissal
was also the download button. The summary is the useful part, so it is shown,
with the download offered as an action beside it. That function has no caller
in the wizard's markup (the "Usage data" button is the creator's, calling the
creator's own showAnalytics), and it was converted rather than deleted:
deleting a feature is not what this task was asked to do, and its dialog
counted toward the number this task has to drive to zero.
html/work-package-suite.html #wp-toast, an error box on the textarea
html/work-package-suite-app.js STEP_GATES widened; per-field messages;
ensureErrorBoxes; wizardToast; 13 removals
html/work-package-suite-styles.css .wp-toast
tests/validation_check.py new - 81 checks
tests/stepper_check.py its "the alert T5.8 still owns" check now
asserts the opposite, by name
Done when
[x] every step with required fields validates them - 5 steps, from the markup
[x] each error renders at its field and is associated via aria-describedby
[x] submitting an invalid step focuses AND scrolls to the first error
(scroll checked by bounding box, not by trusting scrollIntoView)
[x] errors announce to screen readers
[x] the wizard's native dialog count is 0
The count, recorded both ways because BL-017 says the metric counts prose
work-package-suite-app.js 0 raw, 0 with comments stripped
app-wide 64 raw, 64 stripped, against wave 0's 79
wp-creation-app.js 43 (wave 7), users.js 10 and
admin.js 6 and index.html 5 (wave 9)
The wizard contributes none of what is left, which the probe asserts rather
than leaving to the total.
Verified one at a time
validation_check 81/81 new
stepper_check 71/71
sections_check 88/88
browser_check 71/71
a11y 22/22
url_state 23/23
autosave 34/34
locations 58/58
aggregates 16/16
pipeline 43/43
launcher 58/58
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
No colour literal added. The toast says "error" by a red rule AND by staying
until dismissed where a confirmation times out - two channels, not one (C1).
Question for the PR, per CLAUDE.md: step 3's two role TITLES are validated
because the markup marks them required, but the two role NAME pickers beside
them are not marked and so are not gated. A sign-off role with nobody in it is
arguably the more useful thing to catch. The markup is what was built to; if the
intent was the names, that is two rows in STEP_GATES.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/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/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/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
|
`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` |
|
| — | `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` |
|
| — | helper-text contrast, worst case | 3.01:1 | **4.56:1** | `T4.6` |
|
||||||
| 3 | `<div onclick>` | 12 | **2** | `T5.1` |
|
| 3 | `<div onclick>` | 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, `<span onclick>`, `.help-tip` badges) are wave 9's to
|
Metrics 2, 4 and 6 (creator dialogs, `<span onclick>`, `.help-tip` badges) are wave 9's to
|
||||||
move and are unchanged.
|
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
|
are wave 7 / wave 9 work. Nothing in the SOP wizard's markup carries a click handler on a
|
||||||
non-interactive element any more.
|
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
|
**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
|
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
|
were removing. That is why the figure was 80 before `T5.1` rather than the 79 wave 0
|
||||||
|
|||||||
@@ -388,7 +388,8 @@ function loadSampleData(){
|
|||||||
if(currentTool && currentTool !== 'sop'){
|
if(currentTool && currentTool !== 'sop'){
|
||||||
const f = document.getElementById('wp-frame');
|
const f = document.getElementById('wp-frame');
|
||||||
if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); }
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Populate Step 1
|
// Populate Step 1
|
||||||
@@ -454,7 +455,7 @@ function loadSampleData(){
|
|||||||
collectStepData();
|
collectStepData();
|
||||||
track('sample_loaded');
|
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
|
// 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.
|
// 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 inp = document.getElementById('custom-constraint-input');
|
||||||
const name = (inp && inp.value || '').trim();
|
const name = (inp && inp.value || '').trim();
|
||||||
if(!name){ if(inp) inp.focus(); return; }
|
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:''});
|
state.constraints.push({name, description:''});
|
||||||
if(inp) inp.value='';
|
if(inp) inp.value='';
|
||||||
renderStandardConstraints();
|
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) ───────────────────────────────────
|
// ── STEP 12: WORK PACKAGE SECTIONS (CR-006) ───────────────────────────────────
|
||||||
// Which sections of a work package this project uses. The list itself lives in
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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 = {
|
const STEP_GATES = {
|
||||||
1: {fields: ['proj_name', 'proj_number', 'proj_client', 'proj_division', 'proj_site'],
|
1: {fields: [
|
||||||
msg: 'Please complete all required fields: Project Name, Number, Client, Division, and Site Location.'},
|
['proj_name', 'Enter the project name.'],
|
||||||
5: {fields: ['gov_woformat'], msg: 'Please enter a Work Package Number Format.'},
|
['proj_number', 'Enter the project number.'],
|
||||||
6: {fields: ['qual_qcreq'], msg: 'Please select a QC requirement.'}
|
['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',
|
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',
|
6:'Quality', 7:'Platforms', 8:'Sequence', 9:'Constraints', 10:'Sources',
|
||||||
11:'Locations', 12:'Sections'};
|
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.
|
// unsaved change and fire T4.3's unsaved-work guard on the way out.
|
||||||
const _visitedSteps = new Set([1]);
|
const _visitedSteps = new Set([1]);
|
||||||
|
|
||||||
|
function fieldFilled(id){
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return !!(el && String(el.value || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
function stepGateMet(n){
|
function stepGateMet(n){
|
||||||
const gate = STEP_GATES[n];
|
return stepGateFields(n).every(fieldFilled);
|
||||||
if(!gate) return true;
|
|
||||||
return gate.fields.every(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
return !!(el && String(el.value || '').trim());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the rail may offer, stated as the guard already behaved rather than as
|
// 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){
|
function goToStep(n, opts){
|
||||||
const fromUrl = !!(opts && opts.fromUrl);
|
const fromUrl = !!(opts && opts.fromUrl);
|
||||||
// Restoring a step from the URL is not a forward navigation, so it must not run
|
// 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
|
// the forward-navigation guard. validateStep marks every empty required field
|
||||||
// field is empty, which on a freshly-loaded deep link is ALWAYS - so a shared
|
// and focuses the first, which on a freshly-loaded deep link would mean landing
|
||||||
// link to step 3 opened a modal dialog before the page had finished booting.
|
// 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
|
// 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 —
|
// 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];
|
const gate = STEP_GATES[currentStep];
|
||||||
railMessage(`Step ${n}, ${STEP_LABELS[n]}, is not available yet — finish step ${currentStep}, ${STEP_LABELS[currentStep]}, first.`);
|
railMessage(`Step ${n}, ${STEP_LABELS[n]}, is not available yet — finish step ${currentStep}, ${STEP_LABELS[currentStep]}, first.`);
|
||||||
if(!gate) return;
|
if(!gate) return;
|
||||||
const missing = gate.fields
|
const missing = stepGateFields(currentStep)
|
||||||
.map(id => document.getElementById(id))
|
.map(id => document.getElementById(id))
|
||||||
.find(el => el && !String(el.value || '').trim());
|
.find(el => el && !String(el.value || '').trim());
|
||||||
if(missing){
|
if(missing){
|
||||||
@@ -1842,13 +1930,16 @@ function collapseRailIfNarrow(){
|
|||||||
// which is worse than the strip it replaced, because that one at least lied
|
// which is worse than the strip it replaced, because that one at least lied
|
||||||
// consistently.
|
// consistently.
|
||||||
const _GATE_FIELD_IDS = new Set(
|
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){
|
function railWatchField(e){
|
||||||
const t = e.target;
|
const t = e.target;
|
||||||
if(!t || !t.id || !_GATE_FIELD_IDS.has(t.id)) return;
|
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
|
// An error message is about a state that no longer holds once the field it
|
||||||
// named has been filled.
|
// 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('');
|
if(stepGateMet(currentStep)) railMessage('');
|
||||||
renderStepRail();
|
renderStepRail();
|
||||||
}
|
}
|
||||||
@@ -1856,6 +1947,7 @@ function railWatchField(e){
|
|||||||
document.addEventListener('DOMContentLoaded', function(){
|
document.addEventListener('DOMContentLoaded', function(){
|
||||||
const list = document.getElementById('step-rail-list');
|
const list = document.getElementById('step-rail-list');
|
||||||
const toggle = document.getElementById('step-rail-toggle');
|
const toggle = document.getElementById('step-rail-toggle');
|
||||||
|
ensureErrorBoxes();
|
||||||
document.addEventListener('input', railWatchField);
|
document.addEventListener('input', railWatchField);
|
||||||
document.addEventListener('change', railWatchField);
|
document.addEventListener('change', railWatchField);
|
||||||
if(toggle){
|
if(toggle){
|
||||||
@@ -1964,16 +2056,88 @@ function collectStepData(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same three gates, same three messages, read from STEP_GATES so the rail and the
|
// ── INLINE VALIDATION (S1 wizard half / T5.8) ────────────────────────────────
|
||||||
// guard can never disagree about which steps are open. T5.8 replaces the dialog
|
// Was one native dialog per step, naming no field. Now: an error at each field
|
||||||
// here with inline field errors; the table above is what it will validate against.
|
// 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){
|
function validateStep(n){
|
||||||
collectStepData();
|
collectStepData();
|
||||||
if(!stepGateMet(n)){
|
const gate = STEP_GATES[n];
|
||||||
alert(STEP_GATES[n].msg);
|
if(!gate) return true;
|
||||||
return false;
|
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 ────────────────────────────────────────────────────────────
|
// ── SOP COMPLETION ────────────────────────────────────────────────────────────
|
||||||
@@ -1986,8 +2150,9 @@ function canEditCompletedSOP(){
|
|||||||
|
|
||||||
function completeSOP(){
|
function completeSOP(){
|
||||||
if(sopComplete && !canEditCompletedSOP()){
|
if(sopComplete && !canEditCompletedSOP()){
|
||||||
alert('This project\'s SOP is already complete, and changing it needs the Project Admin role.\n\n' +
|
wizardToast('This project\'s SOP is already complete, and changing it needs the Project Admin '
|
||||||
'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
|
+ 'role. Ask a project admin — the SOP is the baseline every work package inherits.',
|
||||||
|
{role: 'alert'});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(!validateStep(LAST_STEP)) 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.
|
// the user stays), then return to the project home page per the requested flow.
|
||||||
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
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';
|
window.location.href = 'index.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2120,7 +2285,13 @@ function submitComment(){
|
|||||||
const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous';
|
const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous';
|
||||||
const text = document.getElementById('comment-text').value.trim();
|
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 = {
|
const comment = {
|
||||||
step: currentStep,
|
step: currentStep,
|
||||||
@@ -2140,7 +2311,7 @@ function submitComment(){
|
|||||||
function exportComments(){
|
function exportComments(){
|
||||||
const saved = localStorage.getItem('wp_suite_comments');
|
const saved = localStorage.getItem('wp_suite_comments');
|
||||||
const data = saved ? JSON.parse(saved) : [];
|
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 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 blob = new Blob([JSON.stringify(payload,null,2)], {type:'application/json'});
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -2158,7 +2329,7 @@ function importComments(ev){
|
|||||||
try{
|
try{
|
||||||
const inc = JSON.parse(r.result);
|
const inc = JSON.parse(r.result);
|
||||||
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
|
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');
|
const saved = localStorage.getItem('wp_suite_comments');
|
||||||
allComments = saved ? JSON.parse(saved) : [];
|
allComments = saved ? JSON.parse(saved) : [];
|
||||||
const seen = new Set(allComments.map(c=>c.step+'|'+c.timestamp+'|'+c.text));
|
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++; }});
|
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));
|
localStorage.setItem('wp_suite_comments', JSON.stringify(allComments));
|
||||||
loadStepComments();
|
loadStepComments();
|
||||||
alert('Imported ' + added + ' comment' + (added===1?'':'s') + '.');
|
wizardToast('Imported ' + added + ' feedback item' + (added===1?'':'s') + '.');
|
||||||
}catch(e){ alert('Could not read that file.'); }
|
}catch(e){ wizardToast('Could not read that file.', {role: 'alert'}); }
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
};
|
};
|
||||||
r.readAsText(f);
|
r.readAsText(f);
|
||||||
@@ -2240,8 +2411,17 @@ function showAnalytics(){
|
|||||||
txt += `\nActions:\n`;
|
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`);
|
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 += ` field edits: ${s.byEvent['field_edit']||0}\n`;
|
||||||
txt += `\nDownload full event log as JSON?`;
|
// Was a native confirmation carrying the whole summary as its body — a wall of
|
||||||
if(confirm(txt)) downloadAnalytics();
|
// 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(){
|
function downloadAnalytics(){
|
||||||
const data = analyticsLoad();
|
const data = analyticsLoad();
|
||||||
|
|||||||
@@ -754,6 +754,51 @@ body.embed-full { overflow: hidden; }
|
|||||||
.field-toggle.is-off { border-left-color: var(--border-strong); }
|
.field-toggle.is-off { border-left-color: var(--border-strong); }
|
||||||
.field-toggle.is-off .section-toggle-name { color: var(--text-light); }
|
.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
|
/* .field-error is declared once, in theme-light.css — the launcher's create form
|
||||||
and T5.8's step validation use the same component. */
|
and T5.8's step validation use the same component. */
|
||||||
|
|
||||||
|
|||||||
@@ -513,8 +513,9 @@
|
|||||||
<input type="text" id="commenter-name" placeholder="(signed-in user)" readonly title="Taken from your sign-in" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; background: var(--bg);">
|
<input type="text" id="commenter-name" placeholder="(signed-in user)" readonly title="Taken from your sign-in" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; background: var(--bg);">
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom: 1rem;">
|
<div style="margin-bottom: 1rem;">
|
||||||
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
|
<label style="font-weight: 600; font-size: 13px;" for="comment-text">Feedback</label>
|
||||||
<textarea id="comment-text" rows="3" placeholder="Your feedback here..." style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; font-family: inherit;"></textarea>
|
<textarea id="comment-text" rows="3" placeholder="Your feedback here..." aria-describedby="comment-text_err" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; font-family: inherit;"></textarea>
|
||||||
|
<div class="field-error" id="comment-text_err" role="alert"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
||||||
<button onclick="submitComment()" style="background: var(--primary); color: var(--cds-text-on-color); padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
|
<button onclick="submitComment()" style="background: var(--primary); color: var(--cds-text-on-color); padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
|
||||||
@@ -542,6 +543,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- S1 / T5.8. Every message the wizard used to put in a native dialog lands
|
||||||
|
here instead. role is set per message: an error interrupts, a confirmation
|
||||||
|
does not (T4.5). A dialog cannot be styled, cannot be read by a screen
|
||||||
|
reader as anything but a modal interruption, and — the reason this matters
|
||||||
|
beyond taste — blocks the page until it is dismissed. -->
|
||||||
|
<div id="wp-toast" class="wp-toast" role="status" hidden></div>
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
<script src="feedback-config.js"></script>
|
||||||
<script src="project-data.js"></script>
|
<script src="project-data.js"></script>
|
||||||
<script src="help.js"></script>
|
<script src="help.js"></script>
|
||||||
|
|||||||
@@ -343,13 +343,14 @@ def run(page, base, tok):
|
|||||||
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
|
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
|
||||||
chk("...and the guard refuses the same move",
|
chk("...and the guard refuses the same move",
|
||||||
page.eval("(() => { const n = currentStep; goToStep(4); return currentStep === n; })()"))
|
page.eval("(() => { const n = currentStep; goToStep(4); return currentStep === n; })()"))
|
||||||
# That call went through validateStep() directly rather than through the rail,
|
# Until T5.8 this call raised the wizard's surviving alert, and this check
|
||||||
# so it hit the wizard's surviving alert. Recorded here so the fact is on the
|
# recorded that rather than hiding it. T5.8 replaced it with an error at the
|
||||||
# page rather than hidden, and cleared so check 8 below still means something.
|
# field, so the same call now refuses silently and marks the field instead.
|
||||||
guard_dialogs = json.loads(page.eval("JSON.stringify(window.__dialogs||[])"))
|
guard_dialogs = json.loads(page.eval("JSON.stringify(window.__dialogs||[])"))
|
||||||
chk("...via the wizard's alert, which T5.8 still owns",
|
chk("...with no dialog at all, since T5.8", not guard_dialogs, guard_dialogs)
|
||||||
len(guard_dialogs) == 1 and guard_dialogs[0][0] == "alert", guard_dialogs)
|
chk("...and an error at the field that is in the way",
|
||||||
page.eval("window.__dialogs = []")
|
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",
|
chk("...while going BACKWARDS is still allowed, so nobody is trapped",
|
||||||
page.eval("(() => { currentStep = 4; updateStepUI(); goToStep(2); return currentStep; })()") == 2,
|
page.eval("(() => { currentStep = 4; updateStepUI(); goToStep(2); return currentStep; })()") == 2,
|
||||||
page.eval("currentStep"))
|
page.eval("currentStep"))
|
||||||
|
|||||||
419
tests/validation_check.py
Normal file
419
tests/validation_check.py
Normal file
@@ -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 <label> text, and a red <span>*</span> beside
|
||||||
|
an input (step 3's role titles). Both count — the markup was making a promise
|
||||||
|
either way."""
|
||||||
|
src = open(os.path.join(ROOT, "html", "work-package-suite.html"), encoding="utf-8").read()
|
||||||
|
bounds = [(m.start(), int(m.group(1))) for m in re.finditer(r'id="sop-step-(\d+)"', src)]
|
||||||
|
bounds.append((len(src), None))
|
||||||
|
marked = {}
|
||||||
|
for k in range(len(bounds) - 1):
|
||||||
|
start, n = bounds[k]
|
||||||
|
block = src[start:bounds[k + 1][0]]
|
||||||
|
starred = len(re.findall(r"<label[^>]*>[^<]*\*", block))
|
||||||
|
starred += len(re.findall(r'var\(--danger\)[^>]*>\*<', block))
|
||||||
|
if starred:
|
||||||
|
marked[n] = starred
|
||||||
|
return marked
|
||||||
|
|
||||||
|
|
||||||
|
def field_state(page, ids):
|
||||||
|
return json.loads(page.eval("""JSON.stringify(%s.map(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
const box = document.getElementById(id + '_err');
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
exists: !!el,
|
||||||
|
invalid: el ? el.getAttribute('aria-invalid') : null,
|
||||||
|
describedby: el ? (el.getAttribute('aria-describedby') || '') : '',
|
||||||
|
error: box ? (box.textContent || '').trim() : null,
|
||||||
|
role: box ? box.getAttribute('role') : null,
|
||||||
|
};
|
||||||
|
}))""" % json.dumps(ids)))
|
||||||
|
|
||||||
|
|
||||||
|
def blank(page, ids):
|
||||||
|
page.eval("""(() => {
|
||||||
|
for (const id of %s) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) continue;
|
||||||
|
if (el.tagName === 'SELECT') {
|
||||||
|
if (![...el.options].some(o => o.value === '')) el.add(new Option('', ''), 0);
|
||||||
|
el.value = '';
|
||||||
|
} else { el.value = ''; }
|
||||||
|
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||||
|
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})()""" % json.dumps(ids))
|
||||||
|
|
||||||
|
|
||||||
|
def run(page, base, tok):
|
||||||
|
print("\n1. every step the MARKUP marks required is validated")
|
||||||
|
marked = required_fields_in_markup()
|
||||||
|
chk("the markup marks required fields on 5 steps",
|
||||||
|
sorted(marked) == sorted(STEP_FIELDS), sorted(marked))
|
||||||
|
open_wizard(page, base, tok)
|
||||||
|
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
||||||
|
gated = json.loads(page.eval(
|
||||||
|
"JSON.stringify(Object.keys(STEP_GATES).map(Number).sort((a,b)=>a-b))"))
|
||||||
|
chk("...and the guard now covers exactly those 5, not the old 3",
|
||||||
|
gated == sorted(STEP_FIELDS), gated)
|
||||||
|
for n in sorted(STEP_FIELDS):
|
||||||
|
got = json.loads(page.eval("JSON.stringify(stepGateFields(%d))" % n))
|
||||||
|
chk("step %-2d gates on %d field(s), the ones the markup marks" % (n, len(STEP_FIELDS[n])),
|
||||||
|
sorted(got) == sorted(STEP_FIELDS[n]), got)
|
||||||
|
for n in NO_REQUIRED:
|
||||||
|
chk("step %-2d has no required fields and gates on none" % n,
|
||||||
|
page.eval("JSON.stringify(stepGateFields(%d))" % n) == "[]",
|
||||||
|
page.eval("JSON.stringify(stepGateFields(%d))" % n))
|
||||||
|
|
||||||
|
print("\n2 + 3 + 4. an invalid step marks its fields, focuses the first, announces")
|
||||||
|
for n in sorted(STEP_FIELDS):
|
||||||
|
ids = STEP_FIELDS[n]
|
||||||
|
page.eval("goToStep(%d, {fromUrl:true}); updateStepUI()" % n)
|
||||||
|
settle(0.5)
|
||||||
|
blank(page, ids)
|
||||||
|
ok = page.eval("validateStep(%d)" % n)
|
||||||
|
settle(0.4)
|
||||||
|
st = field_state(page, ids)
|
||||||
|
chk("step %-2d refuses while its required fields are empty" % n, ok is False, ok)
|
||||||
|
chk(" ...an error at every one of them",
|
||||||
|
all(f["error"] for f in st), [f["id"] for f in st if not f["error"]])
|
||||||
|
chk(" ...each naming its own field, not one message for the step",
|
||||||
|
len({f["error"] for f in st}) == len(st), [f["error"] for f in st])
|
||||||
|
chk(" ...associated through aria-describedby",
|
||||||
|
all(f["id"] + "_err" in f["describedby"] for f in st),
|
||||||
|
[(f["id"], f["describedby"]) for f in st])
|
||||||
|
chk(" ...marked aria-invalid", all(f["invalid"] == "true" for f in st),
|
||||||
|
[(f["id"], f["invalid"]) for f in st])
|
||||||
|
chk(" ...announced through a live region",
|
||||||
|
all(f["role"] == "alert" for f in st), [(f["id"], f["role"]) for f in st])
|
||||||
|
chk(" ...and focus is on the FIRST empty one",
|
||||||
|
page.eval("(document.activeElement||{}).id") == ids[0],
|
||||||
|
page.eval("(document.activeElement||{}).id"))
|
||||||
|
|
||||||
|
print("\n an error clears as the field is corrected, not on the next submit")
|
||||||
|
page.eval("goToStep(1, {fromUrl:true}); updateStepUI()")
|
||||||
|
settle(0.4)
|
||||||
|
blank(page, STEP_FIELDS[1])
|
||||||
|
page.eval("validateStep(1)")
|
||||||
|
settle(0.3)
|
||||||
|
chk("the error is showing to begin with",
|
||||||
|
bool(field_state(page, ["proj_name"])[0]["error"]))
|
||||||
|
page.eval("""(() => {
|
||||||
|
const el = document.getElementById('proj_name');
|
||||||
|
el.value = 'Typed';
|
||||||
|
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||||
|
return true;
|
||||||
|
})()""")
|
||||||
|
settle(0.4)
|
||||||
|
after = field_state(page, ["proj_name", "proj_number"])
|
||||||
|
chk("...and clears the moment that field is filled", not after[0]["error"], after[0])
|
||||||
|
chk("...without clearing the ones still empty", bool(after[1]["error"]), after[1])
|
||||||
|
|
||||||
|
print("\n the first error is scrolled to, not only focused")
|
||||||
|
page.eval("goToStep(7, {fromUrl:true}); updateStepUI(); window.scrollTo(0, 0)")
|
||||||
|
settle(0.5)
|
||||||
|
blank(page, STEP_FIELDS[7])
|
||||||
|
page.eval("validateStep(7)")
|
||||||
|
settle(0.9)
|
||||||
|
chk("the focused field is inside the viewport", page.eval("""(() => {
|
||||||
|
const el = document.getElementById('plat_tracking');
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return r.top >= -2 && r.bottom <= window.innerHeight + 2;
|
||||||
|
})()"""))
|
||||||
|
|
||||||
|
print("\n Next is refused, and the rail agrees with the refusal")
|
||||||
|
page.eval("goToStep(1, {fromUrl:true}); updateStepUI()")
|
||||||
|
settle(0.4)
|
||||||
|
blank(page, STEP_FIELDS[1])
|
||||||
|
page.eval("nextStep()")
|
||||||
|
settle(0.5)
|
||||||
|
chk("Next does not leave an invalid step", page.eval("currentStep") == 1,
|
||||||
|
page.eval("currentStep"))
|
||||||
|
chk("...and marks the fields, the same way validateStep does",
|
||||||
|
all(f["error"] for f in field_state(page, STEP_FIELDS[1])))
|
||||||
|
chk("...while the rail shows the steps ahead as unavailable", page.eval(
|
||||||
|
"[...document.querySelectorAll('#step-rail-list .step-btn[aria-disabled=true]')].length")
|
||||||
|
== 11,
|
||||||
|
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn[aria-disabled=true]')]"
|
||||||
|
".map(b => b.dataset.step)"))
|
||||||
|
|
||||||
|
print("\n merely LOOKING at a step does not paint it red")
|
||||||
|
open_wizard(page, base, tok)
|
||||||
|
for n in sorted(STEP_FIELDS):
|
||||||
|
page.eval("goToStep(%d, {fromUrl:true}); updateStepUI()" % n)
|
||||||
|
settle(0.3)
|
||||||
|
marks = json.loads(page.eval(
|
||||||
|
"JSON.stringify([...document.querySelectorAll('.field-error')]"
|
||||||
|
".filter(e => (e.textContent||'').trim()).map(e => e.id))"))
|
||||||
|
chk("no error is shown on a step nobody has tried to leave", not marks, marks)
|
||||||
|
|
||||||
|
print("\n5. the wizard opens no native dialog at all")
|
||||||
|
open_wizard(page, base, tok)
|
||||||
|
# Drive every path that used to raise one.
|
||||||
|
page.eval("loadSampleData()")
|
||||||
|
settle(1.6)
|
||||||
|
chk("loading the sample announces instead of interrupting",
|
||||||
|
page.eval("!document.getElementById('wp-toast').hidden"))
|
||||||
|
chk("...politely, because it is a confirmation not an error",
|
||||||
|
page.eval("document.getElementById('wp-toast').getAttribute('role')") == "status")
|
||||||
|
chk("...and says what happened",
|
||||||
|
"Sample data loaded" in (page.eval(
|
||||||
|
"(document.getElementById('wp-toast')||{}).textContent||''")),
|
||||||
|
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
|
||||||
|
chk("...and can be dismissed from the keyboard", page.eval("""(() => {
|
||||||
|
const b = document.querySelector('#wp-toast .wp-toast-close');
|
||||||
|
if (!b) return false;
|
||||||
|
b.focus();
|
||||||
|
const focused = document.activeElement === b;
|
||||||
|
b.click();
|
||||||
|
return focused && document.getElementById('wp-toast').hidden;
|
||||||
|
})()"""))
|
||||||
|
|
||||||
|
page.eval("switchTool('wp'); loadSampleData()")
|
||||||
|
settle(1.2)
|
||||||
|
chk("loading the sample on the wrong tab interrupts (role=alert)",
|
||||||
|
page.eval("document.getElementById('wp-toast').getAttribute('role')") == "alert",
|
||||||
|
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
|
||||||
|
page.eval("switchTool('sop')")
|
||||||
|
settle(0.6)
|
||||||
|
|
||||||
|
# Through the control a person uses — the modal's text input — not through the
|
||||||
|
# library helper, which has no duplicate message to give.
|
||||||
|
page.eval("""(() => {
|
||||||
|
showConstraintLibrary();
|
||||||
|
const i = document.getElementById('custom-constraint-input');
|
||||||
|
i.value = 'Probe constraint'; addCustomConstraintText();
|
||||||
|
i.value = 'Probe constraint'; addCustomConstraintText();
|
||||||
|
return true;
|
||||||
|
})()""")
|
||||||
|
settle(0.6)
|
||||||
|
chk("adding a duplicate constraint says so without a dialog",
|
||||||
|
"already in the list" in (page.eval(
|
||||||
|
"(document.getElementById('wp-toast')||{}).textContent||''")),
|
||||||
|
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
|
||||||
|
page.eval("closeConstraintModal()")
|
||||||
|
|
||||||
|
page.eval("toggleComments(); document.getElementById('comment-text').value=''; submitComment()")
|
||||||
|
settle(0.6)
|
||||||
|
chk("an empty comment is refused at the field",
|
||||||
|
bool((page.eval("(document.getElementById('comment-text_err')||{}).textContent||''")).strip()),
|
||||||
|
ascii_(page.eval("(document.getElementById('comment-text_err')||{}).textContent||''")))
|
||||||
|
chk("...announced", page.eval(
|
||||||
|
"document.getElementById('comment-text_err').getAttribute('role')") == "alert")
|
||||||
|
chk("...and focused", page.eval("(document.activeElement||{}).id") == "comment-text")
|
||||||
|
page.eval("exportComments()")
|
||||||
|
settle(0.5)
|
||||||
|
chk("exporting nothing says so without a dialog",
|
||||||
|
"no feedback to export" in (page.eval(
|
||||||
|
"(document.getElementById('wp-toast')||{}).textContent||''")).lower(),
|
||||||
|
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
|
||||||
|
page.eval("showAnalytics()")
|
||||||
|
settle(0.5)
|
||||||
|
chk("the usage summary is shown rather than crammed into a confirmation",
|
||||||
|
"Step 1:" in (page.eval("(document.getElementById('wp-toast')||{}).textContent||''")),
|
||||||
|
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''"))[:100])
|
||||||
|
chk("...with the download offered as an action beside it",
|
||||||
|
page.eval("""(() => {
|
||||||
|
const b = document.querySelector('#wp-toast .wp-toast-action');
|
||||||
|
return !!b && /download/i.test(b.textContent);
|
||||||
|
})()"""))
|
||||||
|
|
||||||
|
fired = json.loads(page.eval("JSON.stringify(window.__dialogs||[])"))
|
||||||
|
chk("nothing anywhere in that opened alert / confirm / prompt", not fired, fired[:4])
|
||||||
|
|
||||||
|
print("\n and the source agrees — the count, measured both ways")
|
||||||
|
src = open(os.path.join(ROOT, "html", "work-package-suite-app.js"), encoding="utf-8").read()
|
||||||
|
raw = len(re.findall(r"\b(alert|confirm|prompt)\(", src))
|
||||||
|
code = "\n".join(ln for ln in src.splitlines() if not ln.strip().startswith("//"))
|
||||||
|
stripped = len(re.findall(r"\b(alert|confirm|prompt)\(", code))
|
||||||
|
chk("work-package-suite-app.js: 0 native dialogs, raw count", raw == 0, raw)
|
||||||
|
chk("...and 0 with comments stripped too", stripped == 0, stripped)
|
||||||
|
|
||||||
|
app_raw = app_stripped = 0
|
||||||
|
per_file = {}
|
||||||
|
for name in sorted(os.listdir(os.path.join(ROOT, "html"))):
|
||||||
|
if not name.endswith((".js", ".html")):
|
||||||
|
continue
|
||||||
|
text = open(os.path.join(ROOT, "html", name), encoding="utf-8").read()
|
||||||
|
r = len(re.findall(r"\b(alert|confirm|prompt)\(", text))
|
||||||
|
c = len(re.findall(r"\b(alert|confirm|prompt)\(",
|
||||||
|
"\n".join(ln for ln in text.splitlines()
|
||||||
|
if not ln.strip().startswith("//"))))
|
||||||
|
app_raw += r
|
||||||
|
app_stripped += c
|
||||||
|
if r:
|
||||||
|
per_file[name] = (r, c)
|
||||||
|
print(" app-wide: %d raw, %d with comments stripped (wave 0 recorded 79)"
|
||||||
|
% (app_raw, app_stripped))
|
||||||
|
for name, (r, c) in sorted(per_file.items()):
|
||||||
|
print(" %-26s %3d raw %3d stripped" % (name, r, c))
|
||||||
|
chk("the app-wide count went DOWN against the wave 0 baseline of 79",
|
||||||
|
app_raw < 79, app_raw)
|
||||||
|
chk("...and the wizard contributes none of what is left",
|
||||||
|
"work-package-suite-app.js" not in per_file, sorted(per_file))
|
||||||
|
|
||||||
|
print("\n both widths")
|
||||||
|
for w, label in ((390, "390px"), (1440, "1440px")):
|
||||||
|
page.viewport(w, 900, mobile=(w == 390))
|
||||||
|
open_wizard(page, base, tok)
|
||||||
|
blank(page, STEP_FIELDS[1])
|
||||||
|
page.eval("validateStep(1)")
|
||||||
|
settle(0.6)
|
||||||
|
chk("%s: the error renders under its field" % label, page.eval("""(() => {
|
||||||
|
const f = document.getElementById('proj_name').getBoundingClientRect();
|
||||||
|
const e = document.getElementById('proj_name_err').getBoundingClientRect();
|
||||||
|
return e.height > 0 && e.top >= f.bottom - 2;
|
||||||
|
})()"""))
|
||||||
|
page.eval("wizardToast('probe message at %s')" % label)
|
||||||
|
settle(0.4)
|
||||||
|
chk("%s: an announcement stays inside the viewport" % label, page.eval("""(() => {
|
||||||
|
const r = document.getElementById('wp-toast').getBoundingClientRect();
|
||||||
|
return r.left >= -1 && r.right <= window.innerWidth + 1 && r.width > 0;
|
||||||
|
})()"""), page.eval("JSON.stringify(document.getElementById('wp-toast')"
|
||||||
|
".getBoundingClientRect())"))
|
||||||
|
chk("%s: the page does not scroll sideways" % label,
|
||||||
|
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"))
|
||||||
|
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-validation-")
|
||||||
|
db_path = os.path.join(tmpdir, "check.db")
|
||||||
|
server = None
|
||||||
|
try:
|
||||||
|
tok = seed(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("\nInline validation — S1 (wizard)\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 — errors at the field, and no dialogs left.", "32") + "\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user