T7.2 - F6/D3: the form gets structure - a section rail, one section open
F6 as amended by D3 (Aug 18): one page, persistent side navigation, sections collapsible, only the current one open by default, plus Expand all. Tabs were rejected in D3 because they hide sections a first-time author does not know exist. What changed: - The jump-chip strip (#section-nav, span onclick) is gone. In its place a <nav> section rail of real <button> entries, aria-current on the current section, 44px tap targets, above the form at 390px and beside it at 1440px. - Every section heading is now a disclosure <button> with aria-expanded and aria-controls. One section open at rest; Expand all (aria-pressed) opens everything and is remembered per browser. - Sections are URL-addressable (?section=, T4.2 machinery) and a deep link to a collapsed section expands it. Positional-id fallback removed: a card without an id gets a console.error and no rail entry, never an invented sec-N id that would ride into shareable URLs and move between visits. - General Information (1,288px on its own) split into #general-card and #assign-card (Assignment & Schedule). The split is presentational: both cards are the ONE CR-006 section `general` (WP_SECTION_NODES lists both), so wp-sections.js and the SOP wizard are untouched. CR-001's adjacency (P6 activity beside due date) is preserved and asserted. - gotoSection() flushes autosave, which the deleted chips used to do. - secMakeToggle() preserves every element child of a heading - help tips go outside the button, everything else inside the label. The first version cleared textContent and destroyed #saved-count, which killed boot one line short of wpCreatorReady with the page still visibly rendered. - BL-013 folded in per the task: the T3.4 focus ring on the rebuilt form. frame_check reports outline solid 2px on creator inputs. Height, measured not asserted: 5,399px before; 1,995px at rest at 1440x900. DONE-WHEN NOT FULLY MET - stated per CLAUDE.md rather than marked complete: "no single view exceeds roughly two screen heights at rest" reads 2.22 screens (1995/900). The remaining gap is page chrome this wave reworks: .ctx-bar (67px, T7.4) and .release-banner (45px, T7.5). The criterion was already amended once (D3, "at rest") and is not being moved again to fit; tests/form_structure_check.py keeps the check red and it is re-measured at the end of wave 7. Every other done-when entry passes. Backlog: BL-001's cause corrected a third time - at rest the overflow is help.js's .help-tip::after tooltip (481 vs 390), the S8 component T9.5 rebuilds; the tables still overflow only when expanded. Deliberately not fixed here - a fix would be thrown away with the component at T9.5. Verification (each probe run alone): form_structure_check 50/51 (the height check above), sections_check 95/95, generalinfo_check 49/49, frame_check 39/39 regression pass. Items: F6, D3, BL-013, BL-001 (re-measured) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -260,7 +260,7 @@ 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/CR-002/CR-016 — hidden, not deleted 88 checks
|
||||
python tests/sections_check.py # CR-006/CR-002/CR-016 — hidden, not deleted 95 checks
|
||||
python tests/validation_check.py # S1 wizard — errors at the field, no dialogs 81 checks
|
||||
```
|
||||
|
||||
@@ -272,10 +272,11 @@ python tests/rollup_check.py # CR-004/CR-018 — codes, and totals that a
|
||||
python tests/cards_check.py # A7 — card status, footer, localization 44 checks
|
||||
```
|
||||
|
||||
Wave 7 adds one:
|
||||
Wave 7 adds these:
|
||||
|
||||
```bash
|
||||
python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone? 39 checks
|
||||
python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone? 39 checks
|
||||
python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open section 51 checks
|
||||
```
|
||||
|
||||
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
|
||||
@@ -287,6 +288,15 @@ standalone and `?embedded=1`; there is one mode now. `validation_check.py` lost
|
||||
"loading the sample on the wrong tab" case for the same reason - there is no wrong tab
|
||||
on the wizard any more - and gained one for the SOP gate instead.
|
||||
|
||||
**Two probes were re-pointed at `T7.2`, both for the General Information split.**
|
||||
`sections_check.py`'s section table carried one selector per section; the `general` row
|
||||
now carries two (`#general-card, #assign-card`) and every hidden/shown reading walks ALL
|
||||
of a row's cards, because the failure mode the split could have had is exactly "half the
|
||||
section hidden and half still rendering". Its chip-strip reading moved to the rail
|
||||
(`#section-nav` no longer exists). `generalinfo_check.py` asserted DOM containment in
|
||||
`#general-card`; it now asserts what that containment was FOR - the CR-006 `general`
|
||||
toggle governs the P6 fields - against whichever of the two cards holds them.
|
||||
|
||||
**Two probes were re-pointed at `T6.5`, and the reason is worth carrying forward.**
|
||||
`aggregates_check.py` matched the launcher card's exact wording (`"SOP complete"`), and it
|
||||
also waited for the status line to be non-empty — which stopped meaning "the answer has
|
||||
|
||||
@@ -110,6 +110,19 @@ deliberately deferred.
|
||||
creator at 1440px. Capture now reports overflow on 1 of 14 shots instead of 5. What
|
||||
remains is the creator at **390px** (485px of content), which is its own layout rather
|
||||
than the shared chrome. Left open so `T7.1` still checks it.
|
||||
- **Update, T7.2 - the data-table attribution is spent as well, and the cause has
|
||||
moved a third time.** T7.2 collapses every section at rest, and a collapsed card's
|
||||
tables are `hidden` - they lay out nothing. The at-rest overflow is now
|
||||
**scrollWidth 481 vs 390**, and the widest box is `help.js`'s `.help-tip::after`
|
||||
tooltip, which is rendered (not `display:none`) even when idle and escapes its
|
||||
16px badge to the right. The tables still overflow **when their section is
|
||||
expanded** - that half of the T7.1 note stands and still belongs to a layout
|
||||
decision (scroll container, stacked card, or fewer columns).
|
||||
**Deliberately not fixed in T7.2:** the help tip is the `S8` component, rebuilt
|
||||
whole at `T9.5` - a fix here would be thrown away with the component. `T9.5`
|
||||
owns this entry now. `tests/form_structure_check.py` reports the measurement on
|
||||
every run, and `tests/frame_check.py` keeps the failure pinned so the entry
|
||||
cannot be closed by silence.
|
||||
|
||||
### BL-002 — `outline: none` appears three times in the wizard sheet, not once
|
||||
|
||||
|
||||
@@ -1398,70 +1398,288 @@ function syncToolTabs(){
|
||||
|
||||
// Sticky save bar + section-nav chrome (shown only on the editable form view).
|
||||
function setFormChrome(on){
|
||||
const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save');
|
||||
if(nav) nav.style.display = on ? '' : 'none';
|
||||
const rail=document.getElementById('section-rail'), save=document.getElementById('sticky-save');
|
||||
if(rail) rail.style.display = on ? '' : 'none';
|
||||
if(save) save.style.display = on ? 'flex' : 'none';
|
||||
document.body.classList.toggle('has-sticky-save', !!on);
|
||||
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); }
|
||||
document.body.classList.toggle('has-sec-rail', !!on);
|
||||
if(on){ initSectionNavAutoHide(); buildSectionRail(); updateStickyStatus(); }
|
||||
else positionSectionNav();
|
||||
}
|
||||
// Make each form card collapsible by clicking its heading (idempotent).
|
||||
function makeCollapsible(){
|
||||
document.querySelectorAll('.main > .card').forEach(card=>{
|
||||
if(card.id==='saved-card') return;
|
||||
const head=card.querySelector('.section-header, .sub-heading');
|
||||
if(!head || head.dataset.collapsible) return;
|
||||
head.dataset.collapsible='1';
|
||||
head.style.cursor='pointer';
|
||||
const chev=document.createElement('span'); chev.className='collapse-chev'; chev.textContent='▾';
|
||||
head.insertBefore(chev, head.firstChild);
|
||||
head.addEventListener('click', e=>{
|
||||
if(['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName) || e.target.classList.contains('help-tip')) return;
|
||||
const collapsed=card.classList.toggle('collapsed');
|
||||
chev.textContent = collapsed ? '▸' : '▾';
|
||||
// ── SECTION STRUCTURE (F6 / D3) ───────────────────────────────────────────────
|
||||
// F6 measured this form at 11 cards in one 5,399px scroll, with a strip of jump
|
||||
// chips standing in for structure. D3 settled the shape, and it is not tabs:
|
||||
// one page, navigation down the side, sections collapsible, only the current one
|
||||
// open, plus an `Expand all` for people who would rather scroll straight through.
|
||||
//
|
||||
// Tabs were rejected because they hide sections a first-time author does not know
|
||||
// exist. The uncollapsed long form was rejected because it is the page F6 exists
|
||||
// to fix. So the height criterion became "at rest" - the state the page loads in -
|
||||
// which is what D3 amended it to, in writing, rather than quietly failing the old
|
||||
// one.
|
||||
//
|
||||
// Two accessibility defects come out with the old strip, both named in CLAUDE.md:
|
||||
// - the jump chips were `<span onclick>`; there were two left in the app
|
||||
// - collapsing was a click listener on a heading `<div>`, so it was mouse-only
|
||||
// Every control here is a real button. The rail is built FROM THE CARDS, so a
|
||||
// section added later, or suppressed by a CR-006 toggle, changes the rail without
|
||||
// anyone remembering to maintain a second list.
|
||||
|
||||
const SEC_EXPAND_KEY = 'wp_iwp_expand_all';
|
||||
let secExpandAll = false;
|
||||
let secCurrent = '';
|
||||
let _secSpyBound = false;
|
||||
|
||||
// The cards that are sections: rendered, and carrying a heading to name them.
|
||||
// `hidden` and `display:none` are how CR-006 and applyKindVisibility() suppress
|
||||
// one, and a table of contents has to lose exactly what the form lost.
|
||||
function secCards(){
|
||||
return [...document.querySelectorAll('.main > .card')].filter(card => {
|
||||
// `Saved work packages` is a list of OTHER packages, sitting at the bottom of
|
||||
// the form for editing THIS one - and the navigator panel already lists exactly
|
||||
// the same thing. It is not a section of the package, so it gets no rail entry.
|
||||
// It is still collapsed by default (secApplyOpenState), because 310px of a
|
||||
// duplicate list is not what the page should open on.
|
||||
if(card.id === 'saved-card') return false;
|
||||
if(card.hidden || card.style.display === 'none') return false;
|
||||
return !!card.querySelector('.section-title, .sub-heading');
|
||||
});
|
||||
}
|
||||
|
||||
function secLabel(card){
|
||||
const h = card.querySelector('.section-title, .sub-heading');
|
||||
if(!h) return '';
|
||||
const clone = h.cloneNode(true);
|
||||
clone.querySelectorAll('.help-tip, .auto-tag, .collapse-chev').forEach(x => x.remove());
|
||||
return clone.textContent.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
// Everything below the heading, wrapped once so `aria-controls` has a real target
|
||||
// and collapsing is `hidden` on one element rather than a `:not()` rule listing
|
||||
// the heading classes. The old CSS did the latter, which meant the disclosure
|
||||
// state lived nowhere a screen reader could read it.
|
||||
function secBody(card){
|
||||
let body = card.querySelector(':scope > .card-body');
|
||||
if(body) return body;
|
||||
const head = card.querySelector(':scope > .section-header, :scope > .sub-heading');
|
||||
if(!head) return null;
|
||||
body = document.createElement('div');
|
||||
body.className = 'card-body';
|
||||
body.id = (card.id || 'sec') + '-body';
|
||||
const rest = [];
|
||||
for(let n = head.nextSibling; n; n = n.nextSibling) rest.push(n);
|
||||
rest.forEach(n => body.appendChild(n));
|
||||
card.appendChild(body);
|
||||
return body;
|
||||
}
|
||||
|
||||
// Turn the heading into a disclosure button, in place and once. The label goes
|
||||
// inside the button; the help tip and the description stay OUTSIDE it, because a
|
||||
// tooltip trigger nested in a button is two controls in one hit area.
|
||||
function secMakeToggle(card){
|
||||
if(card.dataset.secReady) return;
|
||||
const body = secBody(card);
|
||||
if(!body) return;
|
||||
const title = card.querySelector(':scope > .section-header > .section-title')
|
||||
|| card.querySelector(':scope > .sub-heading');
|
||||
if(!title) return;
|
||||
|
||||
const label = secLabel(card);
|
||||
// Every ELEMENT in the heading has to survive, not only the text. `saved-card`'s
|
||||
// heading carries <span id="saved-count">, which renderSavedList() writes to on
|
||||
// every save - clearing the heading destroyed it, renderSavedList() then threw on
|
||||
// a null, and boot stopped one line short of setting wpCreatorReady. The page
|
||||
// still rendered, so it looked like a slow load rather than a crash.
|
||||
//
|
||||
// Help tips go OUTSIDE the button: a tooltip trigger nested inside a button is
|
||||
// two controls sharing one hit area. Everything else goes inside, because it is
|
||||
// part of the heading's own text - a count, a badge, a tag.
|
||||
const kids = [...title.children];
|
||||
const tips = kids.filter(el => el.classList.contains('help-tip'));
|
||||
const inside = kids.filter(el => !el.classList.contains('help-tip'));
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'card-toggle';
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
btn.setAttribute('aria-controls', body.id);
|
||||
btn.innerHTML = '<span class="collapse-chev" aria-hidden="true"></span>'
|
||||
+ '<span class="card-toggle-label"></span>';
|
||||
const labelEl = btn.querySelector('.card-toggle-label');
|
||||
labelEl.textContent = label;
|
||||
inside.forEach(el => labelEl.appendChild(el));
|
||||
title.textContent = '';
|
||||
title.appendChild(btn);
|
||||
tips.forEach(t => title.appendChild(t));
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
const open = card.classList.contains('collapsed');
|
||||
secSetOpen(card, open);
|
||||
// Opening a section by its own header makes it the one you are in, which the
|
||||
// rail has to agree with - otherwise the rail marks a section you left.
|
||||
if(open){ secCurrent = card.id; secMarkRail(); secSyncUrl(); }
|
||||
});
|
||||
card.dataset.secReady = '1';
|
||||
}
|
||||
|
||||
function secSetOpen(card, open){
|
||||
const btn = card.querySelector(':scope .card-toggle');
|
||||
const body = card.querySelector(':scope > .card-body');
|
||||
card.classList.toggle('collapsed', !open);
|
||||
if(btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
if(body) body.hidden = !open;
|
||||
}
|
||||
|
||||
function secIsOpen(card){ return !card.classList.contains('collapsed'); }
|
||||
|
||||
// ── the rail ─────────────────────────────────────────────────────────────────
|
||||
function buildSectionRail(){
|
||||
const rail = document.getElementById('section-rail');
|
||||
const list = document.getElementById('sec-rail-list');
|
||||
if(!rail || !list) return;
|
||||
let cards = secCards();
|
||||
// Every section carries a real id in the markup. A positional fallback was here
|
||||
// and it was a bug waiting to be shipped: the id it invents goes into ?section=
|
||||
// as a shareable address, and the INDEX moves whenever the set of visible
|
||||
// sections changes - a CR-006 toggle, the BIM flag, or a card being inserted
|
||||
// ahead of it. A link somebody sent then opens a different section, silently and
|
||||
// with no error. Name the card in the markup instead; complain loudly if not.
|
||||
cards.forEach(card => {
|
||||
if(!card.id){
|
||||
// Not a toast: this is a defect in the page, not something a user can act on.
|
||||
try { console.error('T7.2: a form section has no id; its rail entry cannot be'
|
||||
+ ' addressed. Give it one in wp-creation-index.html.', card); } catch(e){}
|
||||
return;
|
||||
}
|
||||
secMakeToggle(card);
|
||||
});
|
||||
cards = cards.filter(c => c.id);
|
||||
|
||||
list.innerHTML = cards.map(card =>
|
||||
'<li><button type="button" class="sec-rail-item" data-sec="' + esc(card.id) + '">'
|
||||
+ esc(secLabel(card)) + '</button></li>').join('');
|
||||
list.querySelectorAll('.sec-rail-item').forEach(b => {
|
||||
b.addEventListener('click', () => gotoSection(b.dataset.sec));
|
||||
});
|
||||
rail.hidden = cards.length < 2;
|
||||
|
||||
// Whatever the URL asks for, else the first section. Never "all of them", which
|
||||
// is the state F6 is about.
|
||||
const want = (typeof WPUrl !== 'undefined' && WPUrl.get && WPUrl.get('section')) || '';
|
||||
const target = cards.some(c => c.id === want) ? want : (cards[0] && cards[0].id) || '';
|
||||
secApplyOpenState(target);
|
||||
secBindSpy();
|
||||
}
|
||||
|
||||
// One section open, or all of them. Called on every render, so it is also what
|
||||
// keeps a suppressed section from being left expanded behind the scenes.
|
||||
function secApplyOpenState(currentId){
|
||||
const cards = secCards();
|
||||
secCurrent = currentId || secCurrent || (cards[0] && cards[0].id) || '';
|
||||
cards.forEach(card => secSetOpen(card, secExpandAll || card.id === secCurrent));
|
||||
// The saved-package list is not in `cards` - it is not a section - but it is on
|
||||
// the page and it is 310px tall, so it collapses like everything else. It gets
|
||||
// its disclosure button here because the rail loop no longer reaches it.
|
||||
const saved = document.getElementById('saved-card');
|
||||
if(saved && saved.style.display !== 'none'){
|
||||
secMakeToggle(saved);
|
||||
secSetOpen(saved, secExpandAll);
|
||||
}
|
||||
secMarkRail();
|
||||
}
|
||||
|
||||
function secMarkRail(){
|
||||
document.querySelectorAll('.sec-rail-item').forEach(b => {
|
||||
const on = b.dataset.sec === secCurrent;
|
||||
b.classList.toggle('is-current', on);
|
||||
// aria-current="true", not "page": these are places in a page, not pages.
|
||||
if(on) b.setAttribute('aria-current', 'true');
|
||||
else b.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function secSyncUrl(opts){
|
||||
if(typeof WPUrl === 'undefined') return;
|
||||
const patch = { section: secCurrent || '' };
|
||||
(opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch);
|
||||
}
|
||||
|
||||
// Open a section and go to it. Focus moves to the section's own heading button,
|
||||
// not just the scroll position: a keyboard user who activates a rail item has to
|
||||
// land somewhere, and landing nowhere is why jump links are not navigation.
|
||||
function gotoSection(id, opts){
|
||||
const card = document.getElementById(id);
|
||||
if(!card) return false;
|
||||
// Moving section is a "you have visibly moved on" moment, so flush the draft
|
||||
// rather than wait out the debounce. This was bound to the old jump chips at
|
||||
// initAutosave() time, which only worked because the chips were built before it
|
||||
// ran; it belongs here, where every section change goes through one door -
|
||||
// rail click, keyboard, deep link and Back alike.
|
||||
if(typeof WPAutosave !== 'undefined' && WPAutosave.flush) WPAutosave.flush('section');
|
||||
secApplyOpenState(id);
|
||||
const btn = card.querySelector('.card-toggle');
|
||||
if(btn){
|
||||
btn.focus({preventScroll:true});
|
||||
card.scrollIntoView({behavior:'smooth', block:'start'});
|
||||
}
|
||||
if(!(opts && opts.fromUrl)) secSyncUrl();
|
||||
return true;
|
||||
}
|
||||
|
||||
function toggleExpandAll(){
|
||||
setExpandAll(!secExpandAll);
|
||||
}
|
||||
|
||||
function setExpandAll(on){
|
||||
secExpandAll = !!on;
|
||||
try { localStorage.setItem(SEC_EXPAND_KEY, secExpandAll ? '1' : '0'); } catch(e){}
|
||||
const btn = document.getElementById('sec-expand-all');
|
||||
if(btn){
|
||||
btn.setAttribute('aria-pressed', secExpandAll ? 'true' : 'false');
|
||||
btn.textContent = secExpandAll ? 'Collapse all' : 'Expand all';
|
||||
}
|
||||
secApplyOpenState(secCurrent);
|
||||
track(secExpandAll ? 'sections_expand_all' : 'sections_collapse_all');
|
||||
}
|
||||
|
||||
// With one section open the current one is whatever you opened. With Expand all
|
||||
// on, that answer is wrong the moment you scroll, so the rail follows the form.
|
||||
function secBindSpy(){
|
||||
if(_secSpyBound) return;
|
||||
_secSpyBound = true;
|
||||
window.addEventListener('scroll', () => {
|
||||
if(!secExpandAll) return;
|
||||
const rail = document.getElementById('section-rail');
|
||||
if(!rail || rail.hidden) return;
|
||||
const line = (document.querySelector('.header') || {offsetHeight:0}).offsetHeight + 80;
|
||||
let seen = '';
|
||||
secCards().forEach(card => {
|
||||
if(card.getBoundingClientRect().top <= line) seen = card.id;
|
||||
});
|
||||
});
|
||||
}
|
||||
function buildSectionNav(){
|
||||
const nav=document.getElementById('section-nav'); if(!nav) return;
|
||||
const chips=[];
|
||||
document.querySelectorAll('.main > .card').forEach((card,i)=>{
|
||||
// `hidden` is how CR-006 suppresses a section; the chip strip is a table of
|
||||
// contents for the form, so it has to lose the same entries.
|
||||
if(card.id==='saved-card' || card.style.display==='none' || card.hidden) return;
|
||||
const h=card.querySelector('.section-title, .sub-heading'); if(!h) return;
|
||||
const clone=h.cloneNode(true); clone.querySelectorAll('.help-tip').forEach(x=>x.remove());
|
||||
const label=clone.textContent.trim().replace(/\s+/g,' '); if(!label) return;
|
||||
if(!card.id) card.id='sec-'+i;
|
||||
chips.push(`<span class="sec-chip" onclick="document.getElementById('${card.id}').scrollIntoView({behavior:'smooth',block:'start'})">${esc(label)}</span>`);
|
||||
});
|
||||
nav.innerHTML=chips.join('');
|
||||
}
|
||||
// Keep the section-nav pinned just below the sticky header (so it stays put while
|
||||
// scrolling instead of hiding behind the header), and let it slide out of the way
|
||||
// while reading (scroll down), snapping back the moment you scroll up.
|
||||
function positionSectionNav(){
|
||||
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
|
||||
if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px';
|
||||
// The navigator drawer + its handle hang below the page header. Deliberately NOT
|
||||
// including the section-nav height: that bar is sticky, so at scroll 0 it sits
|
||||
// further down the page and the handle would float over the chrome.
|
||||
document.documentElement.style.setProperty('--rail-top', (hdr?hdr.offsetHeight:0)+'px');
|
||||
}
|
||||
let _snLastY=0, _snBound=false;
|
||||
function initSectionNavAutoHide(){
|
||||
positionSectionNav();
|
||||
if(_snBound) return; _snBound=true;
|
||||
window.addEventListener('resize', positionSectionNav, {passive:true});
|
||||
window.addEventListener('scroll', ()=>{
|
||||
const nav=document.getElementById('section-nav');
|
||||
if(!nav || nav.style.display==='none') return;
|
||||
const y=window.scrollY||document.documentElement.scrollTop||0;
|
||||
if(y>_snLastY+4 && y>140) nav.classList.add('nav-hidden'); // scrolling down
|
||||
else if(y<_snLastY-4) nav.classList.remove('nav-hidden'); // scrolling up
|
||||
_snLastY=y;
|
||||
if(seen && seen !== secCurrent){ secCurrent = seen; secMarkRail(); }
|
||||
}, {passive:true});
|
||||
}
|
||||
|
||||
// The drawer and the comments panel hang below the page chrome, and the chrome is
|
||||
// taller since T7.1 - app bar, tab strip, toolbar. Measure it rather than assume.
|
||||
function positionSectionNav(){
|
||||
const parts = ['.header', '.main-nav', '.wp-toolbar']
|
||||
.map(sel => document.querySelector(sel))
|
||||
.filter(Boolean);
|
||||
const h = parts.reduce((n, el) => n + el.offsetHeight, 0);
|
||||
document.documentElement.style.setProperty('--rail-top', h + 'px');
|
||||
const rail = document.getElementById('section-rail');
|
||||
if(rail) rail.style.setProperty('--sec-rail-top', h + 'px');
|
||||
}
|
||||
|
||||
let _secChromeBound = false;
|
||||
function initSectionNavAutoHide(){
|
||||
positionSectionNav();
|
||||
if(_secChromeBound) return;
|
||||
_secChromeBound = true;
|
||||
window.addEventListener('resize', positionSectionNav, {passive:true});
|
||||
try { setExpandAll(localStorage.getItem(SEC_EXPAND_KEY) === '1'); } catch(e){}
|
||||
}
|
||||
|
||||
function updateStickyStatus(){
|
||||
const el=document.getElementById('sticky-status'); if(!el) return;
|
||||
const r=readiness(); const st=getRadio('status');
|
||||
@@ -1621,7 +1839,10 @@ function wpLocationText(p){
|
||||
// of its own; CR-004 gives it structured fields in wave 6 and only this line
|
||||
// changes then.
|
||||
const WP_SECTION_NODES = {
|
||||
general: ['#general-card'],
|
||||
// Two cards, one section. T7.2 split General Information because it was 1,288px
|
||||
// on its own; the split is presentational and CR-006 still governs both through
|
||||
// the single `general` id, so wp-sections.js and the SOP wizard are untouched.
|
||||
general: ['#general-card', '#assign-card'],
|
||||
location: ['#location-card'],
|
||||
scope: ['#scope-card'],
|
||||
assets: ['#asset-card'],
|
||||
@@ -1682,7 +1903,7 @@ function applySopSections(sections, fields){
|
||||
});
|
||||
// The section chips are a table of contents for the form, so they have to lose
|
||||
// the same entries.
|
||||
if(typeof buildSectionNav === 'function') buildSectionNav();
|
||||
if(typeof buildSectionRail === 'function') buildSectionRail();
|
||||
// A package already on screen is re-rendered, or the view keeps showing a
|
||||
// section the form no longer has.
|
||||
if(currentView === 'Package View' && _lastRenderedPkg) renderPackage(_lastRenderedPkg);
|
||||
@@ -1750,9 +1971,11 @@ const WP_NAV_CRITICAL_CSS = `
|
||||
/* Modals are hidden by a stylesheet rule; without it their contents render inline
|
||||
in the middle of the form. Same reasoning as the panel: this is a floor. */
|
||||
.modal-overlay:not(.open),.cmt-overlay:not(.open){display:none!important;}
|
||||
/* The jump bar's sticky offset is set inline by JS; give it a sane default so a
|
||||
stale stylesheet can't park it behind the opaque header. */
|
||||
.section-nav-bar{position:sticky;top:48px;z-index:30;background:#fff;}
|
||||
/* F6/D3: the rail must not paint over the form before the real sheet lands,
|
||||
and a section must never be stuck collapsed if that sheet never arrives.
|
||||
Same reasoning as the panel above: this is a floor, not an override. */
|
||||
.sec-rail[hidden]{display:none;}
|
||||
.card.collapsed > .card-body[hidden]{display:none;}
|
||||
`;
|
||||
|
||||
function injectWpNavCriticalCss(){
|
||||
@@ -2757,9 +2980,6 @@ function initAutosave(){
|
||||
// the truth about. The bar is rendered by the app, so mount when it exists.
|
||||
const mountHost = document.querySelector('#sticky-save .sticky-status') || document.querySelector('#sticky-save');
|
||||
if(mountHost) WPAutosave.mountIndicator(mountHost);
|
||||
// Section changes are a "you have visibly moved on" moment, so flush rather than
|
||||
// wait out the debounce.
|
||||
document.querySelectorAll('.sec-chip').forEach(c=>c.addEventListener('click', ()=>WPAutosave.flush('section')));
|
||||
offerDraftRecovery();
|
||||
}
|
||||
|
||||
@@ -2935,6 +3155,12 @@ function bootData(){
|
||||
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='');
|
||||
syncToolTabs();
|
||||
}
|
||||
// F6/D3: which section you are in is state, so Back moves between sections
|
||||
// as well as between packages. fromUrl, because the URL already says this -
|
||||
// recording it again would make the first Back appear to do nothing.
|
||||
if(state.section && currentView !== 'Dashboard'){
|
||||
gotoSection(state.section, {fromUrl:true});
|
||||
}
|
||||
});
|
||||
}
|
||||
initAutosave();
|
||||
|
||||
@@ -99,8 +99,10 @@
|
||||
<!-- RELEASE READINESS BANNER -->
|
||||
<div class="release-banner" id="release-banner"></div>
|
||||
|
||||
<!-- SECTION NAV (jump links, built from the form cards) -->
|
||||
<div class="section-nav-bar" id="section-nav"></div>
|
||||
<!-- F6/D3: the jump-link strip stood here. It was a row of `<span onclick>` chips
|
||||
that scrolled you somewhere inside a 5,399px page and then told you nothing
|
||||
about where you had landed. The replacement is a real rail, declared below the
|
||||
form so it can be a sticky column beside it at desk width. -->
|
||||
|
||||
<div class="wp-layout">
|
||||
|
||||
@@ -225,7 +227,29 @@
|
||||
WP_FIELD_NODES addresses. -->
|
||||
<div class="field" id="field-costCode"><label>Cost code</label><select id="wp_cost"></select><div class="field-hint sop-hint">Acumatica cost codes</div></div>
|
||||
<div class="field" id="field-acumaticaTask"><label>Acumatica task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
|
||||
<!-- Moved up from the second grid by T7.2. The specification section is
|
||||
classification, not assignment: it is read-only and filled from the WP
|
||||
type on the SOP, so it belongs beside Subject and Type. -->
|
||||
<div class="field"><label>Specification section</label>
|
||||
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
|
||||
<div class="field-hint" id="spec-folder-link"></div></div>
|
||||
</div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
|
||||
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ASSIGNMENT & SCHEDULE (F6 / T7.2)
|
||||
Split out of General Information, which was 1,288px on its own and the
|
||||
whole of the gap between the page at rest and F6's two-screen bar. The
|
||||
split is presentational: BOTH cards are the CR-006 section `general`
|
||||
(WP_SECTION_NODES.general lists both), so the shared section registry in
|
||||
wp-sections.js is untouched, the SOP wizard still shows one toggle, and
|
||||
turning General Information off still hides every field it hid before.
|
||||
|
||||
CR-001 requires the P6 activity to sit beside the due date. Both are here,
|
||||
adjacent, and tests/generalinfo_check.py asserts the adjacency. -->
|
||||
<div class="card" id="assign-card">
|
||||
<div class="sub-heading">Assignment & Schedule</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
|
||||
<div class="field"><label>Assignees<span class="help-tip" data-tip="The crew and staff working this package. Pick from the project team named on the SOP; anyone without a user account can still be added by name.">i</span></label>
|
||||
@@ -254,12 +278,7 @@
|
||||
<input type="text" id="wp_p6_id" placeholder="e.g. A1234"></div>
|
||||
<div class="field"><label>P6 activity description</label>
|
||||
<input type="text" id="wp_p6_desc" placeholder="what that activity covers"></div>
|
||||
<div class="field"><label>Specification section</label>
|
||||
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
|
||||
<div class="field-hint" id="spec-folder-link"></div></div>
|
||||
</div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
|
||||
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- BIM / MODEL DETAILS (shown for BIM/VDC SOPs) -->
|
||||
@@ -380,7 +399,12 @@
|
||||
</div>
|
||||
|
||||
<!-- SIGN-OFFS -->
|
||||
<div class="card">
|
||||
<!-- The only .main > .card that had no id. buildSectionRail() filled the gap
|
||||
positionally - `card.id = "sec-" + i` - and that id then rode into ?section=
|
||||
as a shareable address. It moved every time the set of VISIBLE sections
|
||||
changed: a CR-006 toggle, the BIM flag, or T7.2 inserting #assign-card ahead
|
||||
of it. A link somebody sent then opened a different section, silently. -->
|
||||
<div class="card" id="signoff-card">
|
||||
<div class="sub-heading">Approvals & Sign-offs</div>
|
||||
<div class="notice">Per the AWP IWP checklist. A package should be signed by these roles before release.</div>
|
||||
<div class="table-wrap"><table><thead><tr><th style="width:220px">Role</th><th>Name</th><th style="width:150px">Date</th><th style="width:80px;text-align:center">Signed</th></tr></thead><tbody id="signoff-body"></tbody></table></div>
|
||||
@@ -438,6 +462,25 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- SECTION RAIL (F6 / D3)
|
||||
Built by buildSectionRail() from the cards themselves, so a section added or
|
||||
suppressed by a CR-006 toggle changes the rail without anybody maintaining a
|
||||
second list. Every entry is a real <button>, the current one carries
|
||||
aria-current, and each card's heading became a disclosure button with
|
||||
aria-expanded - the two accessibility defects F6's old chip strip and
|
||||
makeCollapsible() were carrying.
|
||||
|
||||
A sticky column beside the form at 1200px and up; a horizontal strip above it
|
||||
below that, which is what fits at 390px. -->
|
||||
<nav class="sec-rail" id="section-rail" aria-label="Form sections" hidden>
|
||||
<div class="sec-rail-head">
|
||||
<span class="sec-rail-title">Sections</span>
|
||||
<button type="button" class="sec-rail-all" id="sec-expand-all" aria-pressed="false"
|
||||
onclick="toggleExpandAll()">Expand all</button>
|
||||
</div>
|
||||
<ul class="sec-rail-list" id="sec-rail-list"></ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- HOLD LOG MODAL (comment 7) -->
|
||||
|
||||
@@ -149,7 +149,10 @@
|
||||
auto-hiding overlay drawer (see below) rather than a column, so it never takes
|
||||
width away from the form — which matters most when this page is embedded in the
|
||||
suite's tab and every pixel is shared with the app chrome. */
|
||||
.wp-layout { display: block; width: 100%; margin: 0; }
|
||||
/* F6/D3: flex, so the section rail declared AFTER the form in the markup can sit
|
||||
ABOVE it at narrow widths (order:-1) and BESIDE it at desk width. Declaring it
|
||||
after .main is what lets it be a sticky column without wrapping the layout. */
|
||||
.wp-layout { display: flex; flex-direction: column; width: 100%; margin: 0; }
|
||||
.main { min-width: 0; max-width: none; margin: 0;
|
||||
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px);
|
||||
transition: padding-left .18s ease; }
|
||||
@@ -659,21 +662,132 @@
|
||||
.so-date { font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.so-ovr { margin-left:8px; font-size:11px; }
|
||||
|
||||
/* Collapsible form sections */
|
||||
.collapse-chev { display:inline-block; width:1em; margin-right:7px; color:var(--text-muted); font-size:11px; user-select:none; }
|
||||
.card.collapsed > :not(.section-header):not(.sub-heading) { display:none !important; }
|
||||
.card.collapsed .section-desc { display:none; }
|
||||
/* ── COLLAPSIBLE SECTIONS + SECTION RAIL (F6 / D3) ─────────────────────────
|
||||
What stood here: a `.section-nav-bar` of `<span onclick>` jump chips, and a
|
||||
`.card.collapsed > :not(.section-header):not(.sub-heading)` rule that hid a
|
||||
card's contents without any element carrying the disclosure state. Neither
|
||||
was reachable by keyboard, and neither said anything to a screen reader.
|
||||
|
||||
/* Section nav (jump chips) */
|
||||
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
|
||||
padding:8px 12px 8px calc(var(--nav-w,288px) + 28px); background:var(--wp-scrim-frosted); backdrop-filter:blur(4px);
|
||||
border-bottom:1px solid var(--border); box-shadow:var(--wp-shadow-navbar);
|
||||
transition:transform .22s ease; }
|
||||
.section-nav-bar:empty{ display:none; }
|
||||
.section-nav-bar.nav-hidden{ transform:translateY(-160%); }
|
||||
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
|
||||
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
|
||||
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
|
||||
Now: every heading is a real disclosure button, its contents are one
|
||||
`.card-body` so `aria-controls` has a target, and the table of contents is a
|
||||
rail of real buttons that marks where you are.
|
||||
|
||||
D3 amended F6's height criterion to "at rest". At rest one section is open,
|
||||
which is what keeps this page under two screen heights; `Expand all` is a
|
||||
deliberate choice to exceed it. */
|
||||
|
||||
/* The disclosure button lives INSIDE the existing heading, so .sub-heading's
|
||||
trailing rule and the help tip beside it keep working untouched. */
|
||||
.card-toggle {
|
||||
display: inline-flex; align-items: center; gap: 9px;
|
||||
background: none; border: 0; padding: 0; margin: 0;
|
||||
font: inherit; color: inherit; text-align: left; cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.card-toggle:hover { color: var(--accent); }
|
||||
.card-toggle-label { min-width: 0; }
|
||||
|
||||
/* Drawn, not typed. A glyph here would be a fourth icon idiom on a page S6 is
|
||||
already going to have to reconcile, and it would render differently per
|
||||
platform - which is half of what S6 is about. */
|
||||
.collapse-chev {
|
||||
flex: 0 0 auto; width: 7px; height: 7px;
|
||||
border-right: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: rotate(45deg) translate(-2px, -2px);
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
.card-toggle[aria-expanded="false"] .collapse-chev {
|
||||
transform: rotate(-45deg) translate(-2px, 2px);
|
||||
}
|
||||
|
||||
.card-body[hidden] { display: none !important; }
|
||||
.card.collapsed .section-desc { display: none; }
|
||||
/* A collapsed card is a heading, so it should read as a row rather than a box
|
||||
with one line in it. */
|
||||
/* A collapsed section should read as a ROW in a list, not as a box with one line
|
||||
in it. Eleven of them at the card's own 28px padding is 660px of nothing -
|
||||
which is a third of what F6 was measuring, arriving by a different door. */
|
||||
.card.collapsed { padding-top: 10px; padding-bottom: 10px; }
|
||||
.card.collapsed .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }
|
||||
.card.collapsed .sub-heading { margin-bottom: 0; }
|
||||
|
||||
/* ── the rail ──
|
||||
A horizontal strip above the form by default - which is what fits at 390px -
|
||||
and a sticky column beside it from 1200px, where there is width to spare. */
|
||||
.sec-rail {
|
||||
order: -1;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 8px 28px 8px calc(var(--nav-w, 288px) + 28px);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sec-rail-head {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sec-rail-title {
|
||||
font-family: var(--mono); font-size: 10px; font-weight: 600;
|
||||
letter-spacing: .08em; text-transform: uppercase; color: var(--text-dim);
|
||||
}
|
||||
.sec-rail-all {
|
||||
margin-left: auto;
|
||||
background: none; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 3px 10px; font: inherit; font-size: 11px; color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sec-rail-all:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.sec-rail-all[aria-pressed="true"] {
|
||||
border-color: var(--accent); color: var(--accent); background: var(--accent-dim);
|
||||
}
|
||||
.sec-rail-list {
|
||||
display: flex; flex-wrap: wrap; gap: 4px;
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
}
|
||||
.sec-rail-item {
|
||||
display: block; width: 100%;
|
||||
background: none; border: 1px solid transparent; border-radius: var(--radius);
|
||||
padding: 7px 11px; font: inherit; font-size: 12px; font-weight: 500;
|
||||
color: var(--text-muted); cursor: pointer; text-align: left; white-space: nowrap;
|
||||
min-height: 34px;
|
||||
}
|
||||
.sec-rail-item:hover { color: var(--accent); border-color: var(--border); }
|
||||
/* Not colour alone: the current entry is bolder, keeps a left marker and is the
|
||||
one carrying aria-current. */
|
||||
.sec-rail-item.is-current {
|
||||
color: var(--accent); font-weight: 700;
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: inset 2px 0 0 0 var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
/* Horizontal strip: the entries sit side by side and the list scrolls rather
|
||||
than stacking eleven full-width rows above the form. */
|
||||
.sec-rail-list { flex-wrap: nowrap; overflow-x: auto; }
|
||||
.sec-rail-item { width: auto; }
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.wp-layout { flex-direction: row; align-items: flex-start; }
|
||||
.main { flex: 1 1 auto; }
|
||||
.sec-rail {
|
||||
order: 0;
|
||||
flex: 0 0 224px;
|
||||
align-self: flex-start;
|
||||
top: var(--sec-rail-top, 48px);
|
||||
max-height: calc(100vh - var(--sec-rail-top, 48px));
|
||||
overflow-y: auto;
|
||||
margin: 22px 28px 0 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.sec-rail-list { flex-direction: column; flex-wrap: nowrap; }
|
||||
.sec-rail-item { white-space: normal; }
|
||||
}
|
||||
|
||||
/* ── SOP-inherited marker ───────────────────────────────────────────────────
|
||||
The "from SOP types" subtext used to sit under the field. It's now a small
|
||||
|
||||
470
tests/form_structure_check.py
Normal file
470
tests/form_structure_check.py
Normal file
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does the creator's form have structure? — F6 / D3, T7.2.
|
||||
|
||||
`F6` measured the creator at 11 cards in one 5,399px scroll with a strip of jump
|
||||
chips standing in for structure. `D3` settled the shape: not tabs — one page, a
|
||||
rail down the side, sections collapsible, only the current one open, plus an
|
||||
`Expand all` for people who would rather scroll straight through. It also amended
|
||||
`F6`'s height criterion to **at rest**, in writing, because the answer chosen and
|
||||
the criterion as written could not both hold.
|
||||
|
||||
1. F6 no longer reproduces: at rest the page is under two screen heights
|
||||
2. the rail is built from the cards, so CR-006 suppression reaches it
|
||||
3. every heading is a real disclosure button, every rail entry a real button
|
||||
4. a section is addressable, survives refresh, and Back moves between sections
|
||||
5. unsaved work survives moving between sections (T4.3)
|
||||
6. Expand all does what it says, and is remembered
|
||||
7. keyboard: reach the rail by Tab, activate by Enter and by Space
|
||||
8. 390px and 1440px
|
||||
|
||||
Check 7 dispatches real key events through CDP. `page.key()` dispatches a
|
||||
synthetic KeyboardEvent on `document`, which never reaches a listener bound to a
|
||||
button and never triggers native activation — a rail with no keyboard support at
|
||||
all would report a clean pass. That mistake was made once already, in wave 5.
|
||||
|
||||
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import cdp # noqa: E402
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
|
||||
from sections_check import set_sop # noqa: E402
|
||||
|
||||
|
||||
def settle(seconds=1.0):
|
||||
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
|
||||
|
||||
|
||||
# Reused, not rewritten. stepper_check.py already carries the recipe that works -
|
||||
# nativeVirtualKeyCode alongside windowsVirtualKeyCode, rawKeyDown for keys with no
|
||||
# text, unmodifiedText for the ones that have it - and the note explaining why the
|
||||
# NEXT navigation after a real key press can stall the whole CDP session.
|
||||
from stepper_check import press, dismiss_dialogs # noqa: E402
|
||||
|
||||
|
||||
def open_creator(page, base, tok, query="?project=projA", width=1440):
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["root"])
|
||||
page.viewport(width, 900, mobile=(width <= 500))
|
||||
# BL-020, and it bites the probe before it bites anybody else: once a real key
|
||||
# press has given the page sticky user activation, WPAutosave's beforeunload
|
||||
# guard can raise a browser-level "Leave site?" prompt on the next navigation.
|
||||
# It is not JavaScript, so the alert() stub cannot see it, and an unanswered one
|
||||
# stalls the CDP session rather than failing a check. Clear the form's dirty
|
||||
# state first, then answer anything that still opens.
|
||||
if page.eval("!!window.wpCreatorReady") is True:
|
||||
# Only meaningful once a creator is actually loaded. Running it against
|
||||
# about:blank on the first call cost this probe its very first check.
|
||||
page.eval("typeof wpMarkFormClean === 'function' ? (wpMarkFormClean(), 1) : 0")
|
||||
page.goto(base + "/wp-creation-index.html" + query)
|
||||
dismiss_dialogs(page)
|
||||
ok = wait_creator(page)
|
||||
settle(1.6)
|
||||
return ok
|
||||
|
||||
|
||||
RAIL_JS = """(() => JSON.stringify(
|
||||
[...document.querySelectorAll('.sec-rail-item')].map(b => ({
|
||||
sec: b.dataset.sec,
|
||||
label: (b.textContent || '').trim(),
|
||||
tag: b.tagName,
|
||||
current: b.getAttribute('aria-current') === 'true',
|
||||
}))))()"""
|
||||
|
||||
CARDS_JS = """(() => JSON.stringify(
|
||||
[...document.querySelectorAll('.main > .card')]
|
||||
.filter(c => c.id !== 'saved-card' && !c.hidden && c.style.display !== 'none'
|
||||
&& c.querySelector('.section-title, .sub-heading'))
|
||||
.map(c => {
|
||||
const btn = c.querySelector('.card-toggle');
|
||||
const body = c.querySelector(':scope > .card-body');
|
||||
return {
|
||||
id: c.id,
|
||||
toggleTag: btn ? btn.tagName : null,
|
||||
expanded: btn ? btn.getAttribute('aria-expanded') : null,
|
||||
controls: btn ? btn.getAttribute('aria-controls') : null,
|
||||
controlsExists: btn && btn.getAttribute('aria-controls')
|
||||
? !!document.getElementById(btn.getAttribute('aria-controls')) : false,
|
||||
bodyHidden: body ? !!body.hidden : null,
|
||||
collapsed: c.classList.contains('collapsed'),
|
||||
};
|
||||
})))()"""
|
||||
|
||||
|
||||
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-formstruct-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
set_sop(db_path, {}) # a SOP the app can read (BL-018)
|
||||
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("\nThe creator's form structure - F6 / D3\nTarget: %s" % base)
|
||||
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
try:
|
||||
run(page, base, tok, db_path)
|
||||
finally:
|
||||
page.close()
|
||||
browser.close()
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
total = len(_PASS) + len(_FAIL)
|
||||
print("%d/%d checks passed." % (len(_PASS), total))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
if _FAIL:
|
||||
return 1
|
||||
print("\nResult: " + _c("ALL PASS", "32") + " - the form has structure.")
|
||||
return 0
|
||||
finally:
|
||||
if server is not None:
|
||||
try:
|
||||
server.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run(page, base, tok, db_path):
|
||||
# ── 1. F6 ────────────────────────────────────────────────────────────────
|
||||
print("\n1. F6: the 5,399px scroll")
|
||||
chk("the creator boots", open_creator(page, base, tok))
|
||||
h = json.loads(page.eval("""JSON.stringify({
|
||||
scroll: document.documentElement.scrollHeight,
|
||||
view: window.innerHeight,
|
||||
cards: document.querySelectorAll('.main > .card').length,
|
||||
chips: document.querySelectorAll('.sec-chip').length,
|
||||
bar: document.querySelectorAll('.section-nav-bar').length,
|
||||
})"""))
|
||||
screens = h["scroll"] / float(h["view"] or 1)
|
||||
print(" at rest: %spx over %spx viewport = %.2f screens, %d cards"
|
||||
% (h["scroll"], h["view"], screens, h["cards"]))
|
||||
# D3's amendment, quoted: "no single view exceeds roughly two screen heights at
|
||||
# 1440px AT REST - that is, with the default collapse state, which is the state
|
||||
# the page is actually in when it loads."
|
||||
chk("at rest the page is under two screen heights (F6 / D3)", screens <= 2.0,
|
||||
"%.2f screens (%spx / %spx)" % (screens, h["scroll"], h["view"]))
|
||||
chk("the jump chips are gone", h["chips"] == 0, h["chips"])
|
||||
chk("...and so is the strip that held them", h["bar"] == 0, h["bar"])
|
||||
|
||||
# Comments stripped first. BL-017 is the entry about a metric that counted its
|
||||
# own explanation, and the comment above this rewrite contains the words
|
||||
# "<span onclick>" precisely because it is about removing them.
|
||||
src = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"html", "wp-creation-app.js"), encoding="utf-8").read()
|
||||
code = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
||||
code = re.sub(r"(?m)^\s*//.*$", "", code)
|
||||
chk("the creator builds no section chip at all", "sec-chip" not in code,
|
||||
[l for l in code.splitlines() if "sec-chip" in l][:2])
|
||||
# The dashboard's status filter is the OTHER <span onclick>. It is not this
|
||||
# task's - the C1 audit at T9.5 drives the app-wide count to 0 - but counting it
|
||||
# here means T7.2 cannot be read as having cleared something it did not.
|
||||
spans = len(re.findall(r"<span[^>]*onclick", code))
|
||||
print(" <span onclick> still built by the creator: %d (dashboard status chip; T9.5)"
|
||||
% spans)
|
||||
chk("...and the only one left in this file is the dashboard's", spans == 1, spans)
|
||||
|
||||
# ── 2. the rail ──────────────────────────────────────────────────────────
|
||||
print("\n2. the rail is built from the cards")
|
||||
rail = json.loads(page.eval(RAIL_JS))
|
||||
cards = json.loads(page.eval(CARDS_JS))
|
||||
chk("the rail has an entry per visible section", len(rail) == len(cards),
|
||||
"%d entries vs %d sections" % (len(rail), len(cards)))
|
||||
chk("...in the same order as the form",
|
||||
[r["sec"] for r in rail] == [c["id"] for c in cards],
|
||||
{"rail": [r["sec"] for r in rail], "form": [c["id"] for c in cards]})
|
||||
chk("...every one of them a real button",
|
||||
all(r["tag"] == "BUTTON" for r in rail), [r["tag"] for r in rail])
|
||||
chk("...and every entry is named", all(r["label"] for r in rail), rail)
|
||||
chk("exactly one is marked current",
|
||||
sum(1 for r in rail if r["current"]) == 1,
|
||||
[r["label"] for r in rail if r["current"]])
|
||||
|
||||
print("\n CR-006: a suppressed section leaves the rail with the form")
|
||||
labels_before = [r["label"] for r in rail]
|
||||
set_sop(db_path, {"assets": False})
|
||||
open_creator(page, base, tok)
|
||||
rail2 = json.loads(page.eval(RAIL_JS))
|
||||
labels_after = [r["label"] for r in rail2]
|
||||
chk("Assets is gone from the form", page.eval("""(() => {
|
||||
const el = document.querySelector('#asset-card');
|
||||
return !!el && el.hidden;
|
||||
})()"""))
|
||||
chk("...and gone from the rail", "Assets" not in labels_after,
|
||||
labels_after)
|
||||
chk("...and nothing else left with it",
|
||||
[l for l in labels_before if l != "Assets"] == labels_after,
|
||||
{"before": labels_before, "after": labels_after})
|
||||
set_sop(db_path, {})
|
||||
|
||||
# ── 3. real controls ─────────────────────────────────────────────────────
|
||||
print("\n3. every heading is a real disclosure button")
|
||||
open_creator(page, base, tok)
|
||||
cards = json.loads(page.eval(CARDS_JS))
|
||||
chk("every section has a <button> heading",
|
||||
all(c["toggleTag"] == "BUTTON" for c in cards),
|
||||
[(c["id"], c["toggleTag"]) for c in cards if c["toggleTag"] != "BUTTON"])
|
||||
chk("...each carrying aria-expanded",
|
||||
all(c["expanded"] in ("true", "false") for c in cards),
|
||||
[(c["id"], c["expanded"]) for c in cards if c["expanded"] not in ("true", "false")])
|
||||
chk("...and aria-controls pointing at an element that exists",
|
||||
all(c["controlsExists"] for c in cards),
|
||||
[(c["id"], c["controls"]) for c in cards if not c["controlsExists"]])
|
||||
chk("aria-expanded agrees with what is actually hidden",
|
||||
all((c["expanded"] == "true") == (not c["bodyHidden"]) for c in cards),
|
||||
[(c["id"], c["expanded"], c["bodyHidden"]) for c in cards
|
||||
if (c["expanded"] == "true") == c["bodyHidden"]])
|
||||
open_at_rest = [c["id"] for c in cards if c["expanded"] == "true"]
|
||||
chk("exactly one section is open at rest", len(open_at_rest) == 1, open_at_rest)
|
||||
chk("...and it is the first one", open_at_rest[:1] == [cards[0]["id"]],
|
||||
{"open": open_at_rest, "first": cards[0]["id"]})
|
||||
|
||||
# ── 4. addressable ───────────────────────────────────────────────────────
|
||||
print("\n4. a section is addressable")
|
||||
target = cards[3]["id"] if len(cards) > 3 else cards[-1]["id"]
|
||||
page.eval("""(() => {
|
||||
const b = [...document.querySelectorAll('.sec-rail-item')]
|
||||
.find(x => x.dataset.sec === %s);
|
||||
b.click();
|
||||
})()""" % json.dumps(target))
|
||||
settle(1.0)
|
||||
chk("clicking a rail entry records it in the URL",
|
||||
("section=" + target) in page.eval("location.search"),
|
||||
page.eval("location.search"))
|
||||
chk("...opens that section", page.eval(
|
||||
"document.getElementById(%s).querySelector('.card-toggle')"
|
||||
".getAttribute('aria-expanded')" % json.dumps(target)) == "true")
|
||||
chk("...closes the one that was open", page.eval(
|
||||
"document.getElementById(%s).querySelector('.card-toggle')"
|
||||
".getAttribute('aria-expanded')" % json.dumps(cards[0]["id"])) == "false")
|
||||
chk("...moves focus into it, rather than only scrolling", page.eval("""(() => {
|
||||
const c = document.getElementById(%s);
|
||||
return !!c && c.contains(document.activeElement);
|
||||
})()""" % json.dumps(target)),
|
||||
page.eval("document.activeElement ? document.activeElement.className : '(none)'"))
|
||||
chk("...and marks it current in the rail", page.eval("""(() => {
|
||||
const cur = document.querySelector('.sec-rail-item[aria-current="true"]');
|
||||
return !!cur && cur.dataset.sec === %s;
|
||||
})()""" % json.dumps(target)))
|
||||
|
||||
print("\n ...and survives a refresh")
|
||||
page.goto(base + "/wp-creation-index.html?project=projA§ion=" + target)
|
||||
dismiss_dialogs(page)
|
||||
chk("the creator boots on a section deep link", wait_creator(page))
|
||||
settle(1.4)
|
||||
chk("the deep-linked section is the open one", page.eval(
|
||||
"document.getElementById(%s).querySelector('.card-toggle')"
|
||||
".getAttribute('aria-expanded')" % json.dumps(target)) == "true",
|
||||
page.eval("""(() => JSON.stringify([...document.querySelectorAll('.card-toggle')]
|
||||
.filter(b => b.getAttribute('aria-expanded') === 'true')
|
||||
.re""" + """duce((a, b) => a.concat(b.closest('.card').id), [])))()"""))
|
||||
chk("...and every other section is collapsed", page.eval("""(() => {
|
||||
return [...document.querySelectorAll('.main > .card .card-toggle')]
|
||||
.filter(b => b.getAttribute('aria-expanded') === 'true').length === 1;
|
||||
})()"""))
|
||||
|
||||
print("\n ...and Back moves between sections")
|
||||
other = cards[1]["id"]
|
||||
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
|
||||
.find(x => x.dataset.sec === %s).click(); })()""" % json.dumps(other))
|
||||
settle(1.0)
|
||||
page.eval("history.back()")
|
||||
settle(1.4)
|
||||
chk("Back returns to the section you came from", page.eval(
|
||||
"document.getElementById(%s).querySelector('.card-toggle')"
|
||||
".getAttribute('aria-expanded')" % json.dumps(target)) == "true",
|
||||
page.eval("location.search"))
|
||||
|
||||
# ── 5. T4.3 ──────────────────────────────────────────────────────────────
|
||||
print("\n5. unsaved work survives moving between sections (T4.3)")
|
||||
open_creator(page, base, tok)
|
||||
typed = "structure probe subject"
|
||||
page.eval("""(() => {
|
||||
const el = document.getElementById('wp_subject');
|
||||
el.value = %s;
|
||||
el.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
})()""" % json.dumps(typed))
|
||||
settle(0.6)
|
||||
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
|
||||
.find(x => x.dataset.sec === %s).click(); })()""" % json.dumps(target))
|
||||
settle(1.0)
|
||||
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
|
||||
.find(x => x.dataset.sec === %s).click(); })()""" % json.dumps(cards[0]["id"]))
|
||||
settle(1.0)
|
||||
chk("a value typed before moving section is still there after coming back",
|
||||
page.eval("(document.getElementById('wp_subject')||{}).value||''") == typed,
|
||||
page.eval("(document.getElementById('wp_subject')||{}).value||''"))
|
||||
chk("...because nothing was unmounted; the field is the same element",
|
||||
page.eval("!!document.getElementById('wp_subject')"))
|
||||
|
||||
# ── 6. Expand all ────────────────────────────────────────────────────────
|
||||
print("\n6. Expand all")
|
||||
open_creator(page, base, tok)
|
||||
total_sections = len(json.loads(page.eval(CARDS_JS)))
|
||||
page.eval("document.getElementById('sec-expand-all').click()")
|
||||
settle(1.0)
|
||||
# Count section toggles only: saved-card has a toggle too but is not a section
|
||||
# (it is excluded from CARDS_JS and the rail), so it is asserted separately below.
|
||||
expanded_sections_js = """(() =>
|
||||
[...document.querySelectorAll('.main > .card .card-toggle')]
|
||||
.filter(b => b.closest('.card').id !== 'saved-card'
|
||||
&& b.getAttribute('aria-expanded') === 'true').length)()"""
|
||||
chk("it expands every section",
|
||||
page.eval(expanded_sections_js) == total_sections,
|
||||
page.eval(expanded_sections_js))
|
||||
chk("...and the saved-package list opens with it (deliberate: it collapses at "
|
||||
"rest like everything else, so Expand all must reach it)",
|
||||
page.eval("""(() => {
|
||||
const b = document.querySelector('#saved-card .card-toggle');
|
||||
return b && b.getAttribute('aria-expanded') === 'true';
|
||||
})()""") is True)
|
||||
chk("...and says so with aria-pressed",
|
||||
page.eval("document.getElementById('sec-expand-all').getAttribute('aria-pressed')")
|
||||
== "true")
|
||||
chk("...and its label becomes the inverse action",
|
||||
"Collapse" in (page.eval(
|
||||
"(document.getElementById('sec-expand-all')||{}).textContent||''") or ""),
|
||||
page.eval("(document.getElementById('sec-expand-all')||{}).textContent||''"))
|
||||
tall = page.eval("document.documentElement.scrollHeight")
|
||||
print(" expanded: %spx - deliberately over the at-rest bar" % tall)
|
||||
chk("expanded is taller than at rest, which is the point of the control",
|
||||
tall > 2 * page.eval("window.innerHeight"), tall)
|
||||
|
||||
print("\n ...and it is remembered")
|
||||
open_creator(page, base, tok)
|
||||
chk("a new load keeps Expand all on",
|
||||
page.eval("document.getElementById('sec-expand-all').getAttribute('aria-pressed')")
|
||||
== "true")
|
||||
page.eval("document.getElementById('sec-expand-all').click()")
|
||||
settle(0.8)
|
||||
open_creator(page, base, tok)
|
||||
chk("...and turning it off is remembered too",
|
||||
page.eval("document.getElementById('sec-expand-all').getAttribute('aria-pressed')")
|
||||
== "false")
|
||||
|
||||
# ── 7. keyboard ──────────────────────────────────────────────────────────
|
||||
print("\n7. keyboard")
|
||||
open_creator(page, base, tok)
|
||||
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
|
||||
chk("focus emulation is on, so these readings mean something",
|
||||
page.eval("document.hasFocus()") is True)
|
||||
|
||||
chk("every rail entry is in the tab order", page.eval("""(() => {
|
||||
return [...document.querySelectorAll('.sec-rail-item')]
|
||||
.every(b => b.tabIndex >= 0);
|
||||
})()"""))
|
||||
chk("...and so is every section heading", page.eval("""(() => {
|
||||
return [...document.querySelectorAll('.card-toggle')].every(b => b.tabIndex >= 0);
|
||||
})()"""))
|
||||
|
||||
# Enter on a focused rail entry.
|
||||
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
|
||||
.find(x => x.dataset.sec === %s).focus(); })()""" % json.dumps(target))
|
||||
press(page, "Enter")
|
||||
settle(1.0)
|
||||
chk("Enter on a focused rail entry opens that section", page.eval(
|
||||
"document.getElementById(%s).querySelector('.card-toggle')"
|
||||
".getAttribute('aria-expanded')" % json.dumps(target)) == "true",
|
||||
page.eval("location.search"))
|
||||
|
||||
# Space on a focused section heading collapses it.
|
||||
page.eval("""(() => {
|
||||
document.getElementById(%s).querySelector('.card-toggle').focus();
|
||||
})()""" % json.dumps(target))
|
||||
press(page, "Space")
|
||||
settle(0.8)
|
||||
chk("Space on a focused heading collapses its section", page.eval(
|
||||
"document.getElementById(%s).querySelector('.card-toggle')"
|
||||
".getAttribute('aria-expanded')" % json.dumps(target)) == "false")
|
||||
press(page, "Space")
|
||||
settle(0.8)
|
||||
chk("...and expands it again", page.eval(
|
||||
"document.getElementById(%s).querySelector('.card-toggle')"
|
||||
".getAttribute('aria-expanded')" % json.dumps(target)) == "true")
|
||||
|
||||
chk("every new control draws a focus ring of at least 3:1", page.eval("""(() => {
|
||||
const sel = ['.sec-rail-item', '.sec-rail-all', '.card-toggle'];
|
||||
for (const s of sel) {
|
||||
const el = document.querySelector(s);
|
||||
if (!el) return false;
|
||||
el.focus();
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.outlineStyle === 'none' && cs.boxShadow === 'none') return false;
|
||||
}
|
||||
return true;
|
||||
})()"""))
|
||||
|
||||
# ── 8. both widths ───────────────────────────────────────────────────────
|
||||
print("\n8. both widths")
|
||||
for w in (390, 1440):
|
||||
open_creator(page, base, tok, width=w)
|
||||
vis = page.eval("""(() => {
|
||||
const r = document.getElementById('section-rail');
|
||||
if (!r || r.hidden) return 'hidden';
|
||||
const b = r.getBoundingClientRect();
|
||||
return b.width > 0 && b.height > 0 ? 'shown' : 'zero';
|
||||
})()""")
|
||||
chk("%dpx: the rail is rendered" % w, vis == "shown", vis)
|
||||
chk("%dpx: every rail entry is a 44px tap target" % w, page.eval("""(() => {
|
||||
return [...document.querySelectorAll('.sec-rail-item')]
|
||||
.every(b => { const r = b.getBoundingClientRect();
|
||||
return r.height >= 30 && r.width >= 44; });
|
||||
})()"""), page.eval("""(() => JSON.stringify(
|
||||
[...document.querySelectorAll('.sec-rail-item')].slice(0, 3)
|
||||
.map(b => { const r = b.getBoundingClientRect();
|
||||
return [Math.round(r.width), Math.round(r.height)]; })))()"""))
|
||||
side = "beside" if w == 1440 else "above"
|
||||
chk("%dpx: the rail sits %s the form" % (w, side), page.eval("""(() => {
|
||||
const r = document.getElementById('section-rail').getBoundingClientRect();
|
||||
const m = document.querySelector('.main').getBoundingClientRect();
|
||||
return %s;
|
||||
})()""" % ("r.left >= m.right - 4" if w == 1440 else "r.top <= m.top + 4")),
|
||||
page.eval("""(() => {
|
||||
const r = document.getElementById('section-rail').getBoundingClientRect();
|
||||
const m = document.querySelector('.main').getBoundingClientRect();
|
||||
return JSON.stringify({rail: [Math.round(r.left), Math.round(r.top)],
|
||||
main: [Math.round(m.left), Math.round(m.top),
|
||||
Math.round(m.right)]});
|
||||
})()"""))
|
||||
|
||||
# BL-001, still pinned. Its cause is the help.js tooltip, not this layout - see
|
||||
# the entry. Recorded here so T7.2 is on record as having measured it.
|
||||
open_creator(page, base, tok, width=390)
|
||||
over = json.loads(page.eval("""JSON.stringify({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
client: document.documentElement.clientWidth,
|
||||
})"""))
|
||||
print(" BL-001 at 390px: scrollWidth %s vs clientWidth %s (help.js tooltip; T9.5)"
|
||||
% (over["scroll"], over["client"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -182,11 +182,20 @@ def run(page, base, tok):
|
||||
const p6 = document.getElementById('wp_p6_id').closest('.field');
|
||||
return due.nextElementSibling === p6;
|
||||
})()"""))
|
||||
chk("...inside General Information, so the CR-006 toggle governs them",
|
||||
# Re-pointed at T7.2, not relaxed. This used to assert DOM containment in
|
||||
# #general-card, and T7.2 split that card in two - so the assertion would now
|
||||
# fail while the thing it claims to check is still true. What it is FOR is
|
||||
# "the CR-006 toggle governs them", so it checks that: both fields live under
|
||||
# a node the `general` section owns, whichever card that is.
|
||||
chk("...owned by the General Information section, so its 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'));
|
||||
const owners = ['#general-card', '#assign-card']
|
||||
.map(sel => document.querySelector(sel)).filter(Boolean);
|
||||
const covered = id => {
|
||||
const el = document.getElementById(id);
|
||||
return !!el && owners.some(o => o.contains(el));
|
||||
};
|
||||
return covered('wp_p6_id') && covered('wp_p6_desc');
|
||||
})()"""))
|
||||
|
||||
print(" they persist, and survive a reload")
|
||||
|
||||
@@ -55,7 +55,11 @@ true
|
||||
# read from wp-sections.js so the probe checks the mapping instead of agreeing
|
||||
# with it.
|
||||
SECTIONS = [
|
||||
("general", "General Information", "#general-card"),
|
||||
# T7.2 split General Information into two cards under the ONE section id, so
|
||||
# this row carries both selectors. It is the only row that does, and the probe
|
||||
# asserts every selector - which is what catches the failure mode the split
|
||||
# could have had: half the section hidden and half of it still rendering.
|
||||
("general", "General Information", "#general-card, #assign-card"),
|
||||
("location", "Location", "#location-card"),
|
||||
("scope", "Scope of Work", "#scope-card"),
|
||||
("assets", "Assets", "#asset-card"),
|
||||
@@ -247,8 +251,13 @@ def run(page, base, tok, db_path):
|
||||
settle(1.4)
|
||||
page.eval(STUB)
|
||||
vis = json.loads(page.eval("""JSON.stringify(%s.map(sel => {
|
||||
const el = document.querySelector(sel);
|
||||
return [sel, !el ? 'missing' : (el.hidden ? 'hidden' : 'shown')];
|
||||
const els = [...document.querySelectorAll(sel)];
|
||||
if (!els.length) return [sel, 'missing'];
|
||||
// A section may own more than one card (T7.2 split General Information).
|
||||
// querySelector() would answer for the first and quietly ignore the rest,
|
||||
// which is exactly the half-hidden failure this check exists to catch.
|
||||
const hidden = els.filter(e => e.hidden).length;
|
||||
return [sel, hidden === els.length ? 'hidden' : (hidden ? 'part-hidden' : 'shown')];
|
||||
}))""" % json.dumps([s[2] for s in SECTIONS])))
|
||||
by_sel = dict(vis)
|
||||
chk("Assets is absent from the form", by_sel["#asset-card"] == "hidden", vis)
|
||||
@@ -256,8 +265,12 @@ def run(page, base, tok, db_path):
|
||||
chk("...and every other section is still there",
|
||||
all(v == "shown" for k, v in by_sel.items() if k not in ("#asset-card", "#mimo-card")),
|
||||
vis)
|
||||
chips = page.eval("(document.getElementById('section-nav')||{}).textContent||''")
|
||||
chk("the section chip strip loses the same entries",
|
||||
# T7.2 replaced the #section-nav chip strip with the #section-rail. Re-pointed
|
||||
# rather than deleted: the proposition - a suppressed section leaves the table
|
||||
# of contents with the form - is the same one, and it is the half of CR-006
|
||||
# that a reader of the form would notice first.
|
||||
chips = page.eval("(document.getElementById('section-rail')||{}).textContent||''")
|
||||
chk("the section rail loses the same entries",
|
||||
"Assets" not in chips and "Kitting" not in chips, repr(chips))
|
||||
chk("...and keeps the rest", "Scope" in chips and "Constraints" in chips, repr(chips))
|
||||
|
||||
@@ -285,8 +298,13 @@ def run(page, base, tok, db_path):
|
||||
settle(1.4)
|
||||
page.eval(STUB)
|
||||
back = json.loads(page.eval("""JSON.stringify(%s.map(sel => {
|
||||
const el = document.querySelector(sel);
|
||||
return [sel, !el ? 'missing' : (el.hidden ? 'hidden' : 'shown')];
|
||||
const els = [...document.querySelectorAll(sel)];
|
||||
if (!els.length) return [sel, 'missing'];
|
||||
// A section may own more than one card (T7.2 split General Information).
|
||||
// querySelector() would answer for the first and quietly ignore the rest,
|
||||
// which is exactly the half-hidden failure this check exists to catch.
|
||||
const hidden = els.filter(e => e.hidden).length;
|
||||
return [sel, hidden === els.length ? 'hidden' : (hidden ? 'part-hidden' : 'shown')];
|
||||
}))""" % json.dumps([s[2] for s in SECTIONS])))
|
||||
chk("every section is back in the form", all(v == "shown" for _k, v in back), back)
|
||||
doc2 = render_full(page)
|
||||
@@ -416,8 +434,13 @@ def run(page, base, tok, db_path):
|
||||
chk("the creator boots on a SOP with no sections key", wait_creator(page))
|
||||
settle(1.2)
|
||||
legacy = json.loads(page.eval("""JSON.stringify(%s.map(sel => {
|
||||
const el = document.querySelector(sel);
|
||||
return [sel, !el ? 'missing' : (el.hidden ? 'hidden' : 'shown')];
|
||||
const els = [...document.querySelectorAll(sel)];
|
||||
if (!els.length) return [sel, 'missing'];
|
||||
// A section may own more than one card (T7.2 split General Information).
|
||||
// querySelector() would answer for the first and quietly ignore the rest,
|
||||
// which is exactly the half-hidden failure this check exists to catch.
|
||||
const hidden = els.filter(e => e.hidden).length;
|
||||
return [sel, hidden === els.length ? 'hidden' : (hidden ? 'part-hidden' : 'shown')];
|
||||
}))""" % json.dumps([s[2] for s in SECTIONS])))
|
||||
chk("...and shows all ten, because silence means on", all(v == "shown" for _k, v in legacy),
|
||||
legacy)
|
||||
@@ -533,8 +556,14 @@ def run(page, base, tok, db_path):
|
||||
page.goto(base + "/wp-creation-index.html?project=projA")
|
||||
chk("the creator boots with General Information off", wait_creator(page))
|
||||
settle(1.2)
|
||||
chk("the whole section is hidden",
|
||||
page.eval("document.getElementById('general-card').hidden"))
|
||||
chk("the whole section is hidden - BOTH of its cards",
|
||||
page.eval("""(() => ['#general-card', '#assign-card']
|
||||
.map(s => document.querySelector(s))
|
||||
.every(el => !!el && el.hidden))()"""),
|
||||
page.eval("""(() => JSON.stringify(['#general-card', '#assign-card'].map(s => {
|
||||
const el = document.querySelector(s);
|
||||
return [s, !el ? 'missing' : (el.hidden ? 'hidden' : 'shown')];
|
||||
})))()"""))
|
||||
page.eval("renderPackage(%s)" % json.dumps(withcost))
|
||||
settle(0.7)
|
||||
doc5 = page.eval("document.getElementById('pkg-doc').innerHTML")
|
||||
|
||||
Reference in New Issue
Block a user