T6.3/T6.4 - CR-004 and CR-018: picked not typed, and totals that add up
CR-004 and CR-018 are the same change seen from two ends. CR-018 is why the
Acumatica cost code came out rather than being relabelled — the tracking
dimension the team wants is floor and area, not an accounting code — and CR-004
is what makes that dimension exist. Committed together because a rollup keyed on
free text is not a rollup, and structured location with nothing rolling up by it
is a form change nobody asked for.
CR-004 - three dependent dropdowns
Building filters Floor filters Sector, off the project's own taxonomy from
T5.4. Clearing a parent clears its children: not doing that is how a package
ends up filed under a floor that is not in the building it claims.
PATHS are stored, not names and not bare codes. A floor's own code is not
unique across buildings; `B-ONE/L1` is. That is what lets the dashboard filter
by a building and match everything beneath it with a prefix test, and it is
what CR-018 groups on.
The list is fetched with include_inactive=true, which is not a contradiction of
CR-005's "deactivating hides it from new work packages" — they are two
questions. What may be CHOSEN is active only. What may be SHOWN is everything,
because a package already referencing a deactivated value still has to render
its label, and blanking it on open would write the blank back on the next save.
A deactivated value that IS on the package is offered, labelled "(no longer
offered)"; on a fresh package it is not offered at all. Both checked.
X5, checked the way aggregates_check checks its own: localStorage is poisoned
with a fake building and the dropdown is required to ignore it.
wp_location survives as a hidden field. A package written before this keeps
what it said, and the form says so rather than dropping it.
CR-018 - the rollup
LOCATION_DIMENSIONS is now ("building", "floor", "sector"). T4.1's note said
"only this tuple and the keys inside each group change - the response shape
does not", and that held exactly.
Rolled up at EVERY level, server-side, not just at the leaf. "How many on
floor 2" is the question CR-018 asks and it is a level above the leaf groups;
summing them in the browser would be the same per-browser arithmetic B4
removed. Actual Hours rolls up along the same dimensions - that is the field
CR-017 retained, and this is why that decision mattered.
Packages with no location are an explicit "(unassigned)" row, not a gap. The
reason is arithmetic: a group set that silently omits them does not add up to
the project total, and a rollup that does not reconcile is decoration. The
probe checks every level sums to the project total, and to the estimated and
actual hour totals, using distinct primes so a mis-sum cannot land on the
right number by luck.
A package with a building but no floor lands in the floor-level unassigned row
alongside the one with no location at all - which is the honest answer, and is
asserted by its hours rather than by its count.
Free text captured before CR-004 groups under itself as a building rather than
collapsing into unassigned, one level deep. Pretending free text is a
hierarchy would file "FAB / LVL 1" under a building called "FAB / LVL 1".
server/app.py dimensions, _location_levels, hours per group
html/wp-creation-index.html three selects where the text box was
html/wp-creation-app.js the pickers, the filters, the rollup panel
html/wp-creation-styles.css .loc-picker, .loc-rollup
tests/rollup_check.py new - 63 checks
Done when — CR-004
[x] all three render as dropdowns populated from project configuration
[x] dependent filtering works, and clearing a parent clears its children
[x] values persist as codes; confirmed by reading what collectPackage stored
[x] the dashboard filters by each of the three
[x] a work package referencing a deactivated value still renders correctly
[x] all option data comes from the server - proved by poisoning the cache
Done when — CR-018
[x] the dashboard groups and totals by Building, Floor and Sector
[x] totals reconcile against an unfiltered count, at every level
[x] Actual Hours rolls up along the same dimensions
[x] grouping is computed server-side - proved by putting nine fake packages in
localStorage and requiring the panel to show none of them
[x] work packages with no location appear in an explicit unassigned group
No migration: location lives in the work package's JSON data blob like every
other per-package field. No colour literal added.
Verified one at a time
rollup_check 63/63 new
generalinfo_check 49/49
browser_check 71/71
pipeline 43/43
a11y 22/22
aggregates 16/16
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
Question for the PR, per CLAUDE.md: the dashboard's location filters and the
rollup both key on the path, so a package saved with free text and no codes is
unreachable by any location filter and sits in its own building-level row. That
is correct and it is also a migration question - whether the existing free-text
locations should be mapped onto the taxonomy once the B100 list arrives, or left
as history. Nothing here decides it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1131,6 +1131,10 @@ function collectPackage(){
|
||||
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
||||
type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'),
|
||||
p6Id:gv('wp_p6_id'), p6Desc:gv('wp_p6_desc'), // CR-001
|
||||
// CR-004: codes (paths), not display strings. `location` keeps whatever
|
||||
// free text the package already had — retained, never overwritten.
|
||||
building:gv('wp_building'), floor:gv('wp_floor'), sector:gv('wp_sector'),
|
||||
|
||||
priority:wpPriorityOf({priority: gv('wp_priority')}), // CR-003
|
||||
|
||||
|
||||
@@ -1235,7 +1239,7 @@ function renderPackage(pkg){
|
||||
<tr><th>Type</th><td>${cell(pkg.type)}</td></tr>
|
||||
${pkg.disciplines&&pkg.disciplines.length?`<tr><th>Discipline(s)</th><td>${esc(pkg.disciplines.join(', '))}${pkg.split?' <span style="color:var(--accent);font-size:10px">[MASTER — split into instances]</span>':''}${pkg.instanceOf?` <span style="color:var(--accent);font-size:10px">[instance of ${esc(pkg.parentNumber||'')}]</span>`:''}</td></tr>`:''}
|
||||
<tr><th>System / Facility Code / UPN</th><td>${cell(pkg.system)}</td></tr>
|
||||
${sectionOn('location')?`<tr><th>Location</th><td>${cell(pkg.location)}</td></tr>`:''}
|
||||
${sectionOn('location')?`<tr><th>Location</th><td>${cell(wpLocationText(pkg))}</td></tr>`:''}
|
||||
${fieldOn('costCode')?`<tr><th>Cost Code</th><td>${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}</td></tr>`:''}
|
||||
${fieldOn('acumaticaTask')?`<tr><th>Acumatica Task</th><td>${cell(pkg.wbs)}</td></tr>`:''}
|
||||
<tr><th>Assignees</th><td>${cell(pkg.assignees)}</td></tr>
|
||||
@@ -1443,6 +1447,142 @@ function updateStickyStatus(){
|
||||
else { el.className='sticky-status ss-notready'; el.textContent=`⚠ ${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`+(r.blocking.length?` · ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`:''); }
|
||||
}
|
||||
|
||||
// ── LOCATION (CR-004 / T6.3) ─────────────────────────────────────────────────
|
||||
// Three dependent dropdowns off the project's own taxonomy (CR-005 / T5.4). The
|
||||
// values stored are PATHS — 'B-ONE/L1/S-A' — not display strings, because CR-018
|
||||
// rolls cost up by them and a rollup keyed on a label breaks the day somebody
|
||||
// fixes a typo in it. A floor's own code is not unique across buildings; its path
|
||||
// is.
|
||||
//
|
||||
// Loaded with include_inactive=true, which is not a contradiction of CR-005's
|
||||
// "deactivating hides it from new work packages". Two different questions:
|
||||
//
|
||||
// what may be CHOSEN active only — the option lists below filter on it
|
||||
// what may be SHOWN everything, because a package already referencing a
|
||||
// deactivated value still has to render its label
|
||||
//
|
||||
// Nothing here reads localStorage. X5 is explicit: the option lists cannot come
|
||||
// from the browser's own copy.
|
||||
let wpLocations = [];
|
||||
let wpLocationsLoaded = false;
|
||||
|
||||
const LOC_LEVELS = ['building', 'floor', 'sector'];
|
||||
const LOC_FIELD = {building: 'wp_building', floor: 'wp_floor', sector: 'wp_sector'};
|
||||
|
||||
function locNode(path){
|
||||
return wpLocations.find(n => n.path === path) || null;
|
||||
}
|
||||
function locLabel(path){
|
||||
const n = locNode(path);
|
||||
return n ? n.name : (path || '');
|
||||
}
|
||||
// The label as it should READ on a package — a value that has since been
|
||||
// deactivated says so, rather than looking like any other choice.
|
||||
function locLabelFull(path){
|
||||
const n = locNode(path);
|
||||
if(!n) return path || '';
|
||||
return n.active ? n.name : n.name + ' (no longer offered)';
|
||||
}
|
||||
|
||||
async function loadLocations(){
|
||||
if(!activeProjectId){ wpLocations = []; wpLocationsLoaded = true; buildLocationPickers(); return; }
|
||||
try {
|
||||
const r = await fetch('/api/projects/' + encodeURIComponent(activeProjectId)
|
||||
+ '/locations?include_inactive=true',
|
||||
{credentials:'same-origin', headers:{'Accept':'application/json'}});
|
||||
if(!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const data = await r.json();
|
||||
wpLocations = data.nodes || [];
|
||||
} catch(e){
|
||||
// No cache fallback, deliberately: an option list remembered from last time
|
||||
// can offer a value this project no longer has, and B4's whole objection is
|
||||
// to a per-browser answer that looks authoritative.
|
||||
wpLocations = [];
|
||||
}
|
||||
wpLocationsLoaded = true;
|
||||
buildLocationPickers();
|
||||
}
|
||||
|
||||
/* Fill one level's <select>, filtered to the children of `parent`.
|
||||
|
||||
`keep` is the value already on the package. It is offered even when it is
|
||||
inactive — otherwise opening an old package would silently blank its location
|
||||
and the next save would write that blank back. */
|
||||
function fillLocSelect(level, parent, keep){
|
||||
const sel = document.getElementById(LOC_FIELD[level]);
|
||||
if(!sel) return;
|
||||
const wanted = LOC_LEVELS.indexOf(level);
|
||||
const options = wpLocations.filter(n =>
|
||||
n.level === level
|
||||
&& (wanted === 0 ? !n.parent_id : (n.path.indexOf(parent + '/') === 0
|
||||
&& n.path.split('/').length === wanted + 1))
|
||||
&& (n.active || n.path === keep));
|
||||
const placeholder = wanted === 0 ? 'Building…' : (level === 'floor' ? 'Floor…' : 'Sector…');
|
||||
sel.innerHTML = `<option value="">${esc(placeholder)}</option>`
|
||||
+ options.map(n => `<option value="${esc(n.path)}"${n.path === keep ? ' selected' : ''}>`
|
||||
+ `${esc(locLabelFull(n.path))}</option>`).join('');
|
||||
// A level with nothing under it is disabled rather than an empty dropdown that
|
||||
// looks broken — and a level whose parent is unset is disabled for the same
|
||||
// reason: there is nothing it could sensibly offer yet.
|
||||
const noParent = wanted > 0 && !parent;
|
||||
sel.disabled = noParent || (!options.length && !keep);
|
||||
sel.title = noParent
|
||||
? 'Choose a ' + LOC_LEVELS[wanted - 1] + ' first'
|
||||
: (!options.length ? 'This project has no ' + level + ' values configured here' : '');
|
||||
if(keep) sel.value = keep;
|
||||
}
|
||||
|
||||
function buildLocationPickers(keepValues){
|
||||
const cur = keepValues || {
|
||||
building: (document.getElementById('wp_building') || {}).value || '',
|
||||
floor: (document.getElementById('wp_floor') || {}).value || '',
|
||||
sector: (document.getElementById('wp_sector') || {}).value || '',
|
||||
};
|
||||
fillLocSelect('building', '', cur.building);
|
||||
fillLocSelect('floor', cur.building, cur.building ? cur.floor : '');
|
||||
fillLocSelect('sector', cur.floor, cur.floor ? cur.sector : '');
|
||||
renderLocationNote();
|
||||
}
|
||||
|
||||
// Clearing a parent clears its children. Not doing this is how a package ends up
|
||||
// filed under a floor that is not in the building it claims.
|
||||
function onLocationChange(level){
|
||||
const b = (document.getElementById('wp_building') || {}).value || '';
|
||||
const f = (document.getElementById('wp_floor') || {}).value || '';
|
||||
const s = (document.getElementById('wp_sector') || {}).value || '';
|
||||
if(level === 'building') buildLocationPickers({building: b, floor: '', sector: ''});
|
||||
else if(level === 'floor') buildLocationPickers({building: b, floor: f, sector: ''});
|
||||
else buildLocationPickers({building: b, floor: f, sector: s});
|
||||
}
|
||||
|
||||
function renderLocationNote(){
|
||||
const note = document.getElementById('wp_location_note');
|
||||
if(!note) return;
|
||||
const legacy = (document.getElementById('wp_location') || {}).value || '';
|
||||
if(!wpLocationsLoaded){ note.textContent = 'Loading this project\'s location list…'; return; }
|
||||
if(!wpLocations.length){
|
||||
note.textContent = 'This project has no location list yet — configure it on step 11 of the SOP.';
|
||||
return;
|
||||
}
|
||||
// A package written before CR-004 has free text and no codes. It is SHOWN
|
||||
// rather than dropped: the data is retained, which is the rule everywhere else
|
||||
// in this plan too.
|
||||
note.textContent = legacy && !(document.getElementById('wp_building') || {}).value
|
||||
? 'Previously recorded as free text: “' + legacy + '”. Pick the structured values to replace it.'
|
||||
: '';
|
||||
}
|
||||
|
||||
function wpLocationOf(p){
|
||||
return {building: p.building || '', floor: p.floor || '', sector: p.sector || ''};
|
||||
}
|
||||
// One readable line from whatever a package holds — structured if it has it,
|
||||
// the old free text if that is all there is.
|
||||
function wpLocationText(p){
|
||||
const parts = LOC_LEVELS.map(l => p[l]).filter(Boolean).map(locLabelFull);
|
||||
if(parts.length) return parts.join(' / ');
|
||||
return p.location || '';
|
||||
}
|
||||
|
||||
// ── SECTION TOGGLES (CR-006 / T5.5) ──────────────────────────────────────────
|
||||
// Which sections this project uses. The list is wp-sections.js, shared with the
|
||||
// SOP wizard; this file owns the mapping from a section id to the DOM it governs
|
||||
@@ -1843,6 +1983,7 @@ function loadPackageIntoForm(p){
|
||||
const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';};
|
||||
set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location);
|
||||
set('wp_p6_id',p.p6Id); set('wp_p6_desc',p.p6Desc); // CR-001
|
||||
buildLocationPickers(wpLocationOf(p)); // CR-004
|
||||
set('wp_priority', wpPriorityOf(p)); // CR-003
|
||||
set('wp_assignees',p.assignees); set('wp_distribution',p.distribution);
|
||||
loadPeopleFromPkg(p);
|
||||
@@ -1932,6 +2073,7 @@ function newPackage(){
|
||||
['wp_subject','wp_system','wp_location','wp_p6_id','wp_p6_desc','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
|
||||
const prio=document.getElementById('wp_priority'); if(prio) prio.value=WP_PRIORITY_DEFAULT; // CR-003
|
||||
buildLocationPickers({building:'', floor:'', sector:''}); // CR-004
|
||||
['wp_iff','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||||
onClashChange();
|
||||
pkgKind='iwp'; applyKind();
|
||||
@@ -1973,7 +2115,7 @@ const WPData = {
|
||||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
|
||||
};
|
||||
|
||||
let dashFilter={status:'',discipline:'',q:'',flag:'',priority:''};
|
||||
let dashFilter={status:'',discipline:'',q:'',flag:'',priority:'',building:'',floor:'',sector:''};
|
||||
// A write has to reach the server before the server can count it. The outbox is
|
||||
// the only path writes take, so flush it and then re-read - otherwise the refresh
|
||||
// races the push and shows the pre-write totals, which is the same stale number
|
||||
@@ -1995,7 +2137,7 @@ function dashRefreshAfterWrite(){
|
||||
const DASH_FLAGS = ['ready', 'onhold', 'overdue', 'mine'];
|
||||
|
||||
function dashToggleFlag(f, opts){
|
||||
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:'',priority:''}; }
|
||||
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:'',priority:'',building:'',floor:'',sector:''}; }
|
||||
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
|
||||
dashPage=0;
|
||||
// S3: which slice of the board you are looking at is state, so it belongs in
|
||||
@@ -2090,6 +2232,93 @@ function dashSortRows(rows){
|
||||
});
|
||||
}
|
||||
|
||||
// CR-004: one filter per level, dependent the same way the form's are — picking
|
||||
// a building narrows the floors offered. Rendered only when the project has a
|
||||
// taxonomy: three empty dropdowns on a project that has not configured one are
|
||||
// three controls that cannot do anything.
|
||||
function locFilterSelects(){
|
||||
if(!wpLocations.length) return '';
|
||||
const out = [];
|
||||
LOC_LEVELS.forEach((level, i) => {
|
||||
const parent = i === 0 ? '' : dashFilter[LOC_LEVELS[i - 1]];
|
||||
if(i > 0 && !parent) return; // nothing to narrow to yet
|
||||
const opts = wpLocations.filter(n =>
|
||||
n.level === level
|
||||
&& (i === 0 ? !n.parent_id : (n.path.indexOf(parent + '/') === 0
|
||||
&& n.path.split('/').length === i + 1)));
|
||||
if(!opts.length) return;
|
||||
const label = level.charAt(0).toUpperCase() + level.slice(1);
|
||||
out.push(`<select aria-label="Filter by ${esc(label.toLowerCase())}"`
|
||||
+ ` onchange="dashSetLocationFilter('${level}', this.value)">`
|
||||
+ `<option value="">All ${esc(level)}s</option>`
|
||||
+ opts.map(n => `<option value="${esc(n.path)}"${dashFilter[level] === n.path ? ' selected' : ''}>`
|
||||
+ `${esc(locLabelFull(n.path))}</option>`).join('')
|
||||
+ `</select>`);
|
||||
});
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
// Clearing a parent clears its children here too, for the same reason as on the
|
||||
// form: a floor filter under a different building filters to nothing and looks
|
||||
// like an empty project.
|
||||
function dashSetLocationFilter(level, value){
|
||||
const i = LOC_LEVELS.indexOf(level);
|
||||
dashFilter[level] = value;
|
||||
for(let k = i + 1; k < LOC_LEVELS.length; k++) dashFilter[LOC_LEVELS[k]] = '';
|
||||
dashPage = 0;
|
||||
renderDashboard();
|
||||
}
|
||||
|
||||
// ── CR-018 / T6.4: rollup by building, floor and sector ─────────────────────
|
||||
// This is why the Acumatica cost code was removed rather than relabelled: the
|
||||
// tracking dimension the team wants is floor and area, not an accounting code.
|
||||
//
|
||||
// Every number here is the SERVER's. The browser does not sum the leaf groups to
|
||||
// get a floor total — that would be the same per-browser arithmetic B4 removed,
|
||||
// and it would silently disagree with the header count the moment this browser's
|
||||
// copy is behind. server/app.py::_location_levels does it once, for everyone.
|
||||
function renderLocationRollup(m){
|
||||
const loc = m && m.by_location;
|
||||
if(!loc || !loc.levels) return '';
|
||||
const unset = loc.unassigned_key || '(unassigned)';
|
||||
const levels = (loc.dimensions || []).filter(d => (loc.levels[d] || []).length);
|
||||
if(!levels.length) return '';
|
||||
|
||||
// Each row's `path` is a full location path — a floor is `B-ONE/L1` — so it
|
||||
// resolves straight to a name. A group whose value at this level is unassigned
|
||||
// is SHOWN, not hidden: it is the row that makes the totals add up, and hiding
|
||||
// it is how a rollup ends up quietly missing work.
|
||||
const label = path => path === unset ? 'Unassigned' : locLabelFull(path);
|
||||
|
||||
let h = `<div class="dash-panel"><div class="dash-panel-title">By location</div>`;
|
||||
h += `<div class="field-hint">Totals come from the server and are computed at every level, `
|
||||
+ `so each table adds up to the project on its own. Packages with no location assigned `
|
||||
+ `appear as <strong>Unassigned</strong> rather than being left out.</div>`;
|
||||
levels.forEach(dim => {
|
||||
const rows = loc.levels[dim] || [];
|
||||
const sum = k => rows.reduce((a, r) => a + (r[k] || 0), 0);
|
||||
h += `<div class="loc-rollup"><div class="dash-bd-title">By ${esc(dim)}</div>`
|
||||
+ `<table class="dash-table"><thead><tr>`
|
||||
+ `<th>${esc(dim.charAt(0).toUpperCase() + dim.slice(1))}</th>`
|
||||
+ `<th>WPs</th><th>Ready</th><th>On hold</th><th>Overdue</th>`
|
||||
+ `<th>Est. hrs</th><th>Actual hrs</th></tr></thead><tbody>`;
|
||||
rows.forEach(r => {
|
||||
const isUnset = r.path === unset;
|
||||
h += `<tr${isUnset ? ' class="loc-unassigned"' : ''}>`
|
||||
+ `<td class="row-label">${esc(label(r.path))}</td>`
|
||||
+ `<td>${r.total}</td><td>${r.release_ready}</td><td>${r.on_hold}</td>`
|
||||
+ `<td>${r.overdue}</td><td>${r.est_hours}</td><td>${r.actual_hours}</td></tr>`;
|
||||
});
|
||||
// The total row is what makes "reconciles against an unfiltered count"
|
||||
// checkable by looking rather than by trusting.
|
||||
h += `<tr class="loc-total"><td class="row-label">All ${esc(dim)}s</td>`
|
||||
+ `<td>${sum('total')}</td><td>${sum('release_ready')}</td><td>${sum('on_hold')}</td>`
|
||||
+ `<td>${sum('overdue')}</td><td>${sum('est_hours')}</td><td>${sum('actual_hours')}</td></tr>`;
|
||||
h += `</tbody></table></div>`;
|
||||
});
|
||||
return h + `</div>`;
|
||||
}
|
||||
|
||||
function dashHeaderCells(){
|
||||
return DASH_COLUMNS.map(c => {
|
||||
if(c.sortable === false) return `<th>${esc(c.label)}</th>`;
|
||||
@@ -2270,6 +2499,7 @@ function renderDashboard(){
|
||||
prog+=`<div class="prog-row"><div class="prog-name">${esc(g.name)}</div><div class="prog-bar"><div class="prog-fill" style="width:${g.pct}%"></div></div><div class="prog-pct">${g.pct}% <span class="prog-sub">${g.done}/${g.total}</span></div></div>`; });
|
||||
prog+=`<div class="field-hint" style="margin-top:8px">Weighted by status (Draft 0 · Scheduled 25 · Issued 50 · In Progress 75 · QC 90 · Closed 100%). Archived packages excluded.</div></div>`;
|
||||
h+=prog;
|
||||
h+=renderLocationRollup(m);
|
||||
|
||||
// gating panel — what's blocking release, from the server
|
||||
const gated=m.gating||[];
|
||||
@@ -2290,6 +2520,7 @@ function renderDashboard(){
|
||||
<select onchange="dashFilter.status=this.value;dashPage=0;renderDashboard()">${statusOpts}</select>
|
||||
<select onchange="dashFilter.discipline=this.value;dashPage=0;renderDashboard()">${discOpts}</select>
|
||||
<select aria-label="Filter by priority" onchange="dashFilter.priority=this.value;dashPage=0;renderDashboard()">${prioOpts}</select>
|
||||
${locFilterSelects()}
|
||||
<label class="dash-arch-toggle"><input type="checkbox" ${dashShowArchived?'checked':''} onchange="dashToggleArchived(this.checked)"> Show archived${dashShowArchived?' ('+dashArchived.length+')':''}</label>
|
||||
</div>`;
|
||||
|
||||
@@ -2300,6 +2531,13 @@ function renderDashboard(){
|
||||
if(dashFilter.status && p.status!==dashFilter.status) return false;
|
||||
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
|
||||
if(dashFilter.priority && wpPriorityOf(p)!==dashFilter.priority) return false;
|
||||
// CR-004: filtering by a BUILDING matches every package under it, because the
|
||||
// stored value is a path. Filtering by the floor code alone could not do that.
|
||||
if(dashFilter.building && !((p.building||'')===dashFilter.building
|
||||
|| (p.floor||'').indexOf(dashFilter.building+'/')===0)) return false;
|
||||
if(dashFilter.floor && !((p.floor||'')===dashFilter.floor
|
||||
|| (p.sector||'').indexOf(dashFilter.floor+'/')===0)) return false;
|
||||
if(dashFilter.sector && (p.sector||'')!==dashFilter.sector) return false;
|
||||
if(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false;
|
||||
if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) return false;
|
||||
if(dashFilter.flag==='ready' && !(!p.split && !wpReleaseBlocked(p) && p.status!=='Closed' && p.status!=='Issue')) return false;
|
||||
@@ -2606,11 +2844,19 @@ function defaultOwnerToMe(){
|
||||
if(me && Array.from(sel.options).some(o=>o.value===me)) sel.value=me;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
LOC_LEVELS.forEach(level => {
|
||||
const el = document.getElementById(LOC_FIELD[level]);
|
||||
if(el) el.addEventListener('change', () => onLocationChange(level));
|
||||
});
|
||||
});
|
||||
|
||||
function bootData(){
|
||||
loadStore(); // reads the localStorage cache (hydrated from the server below)
|
||||
bootSOP();
|
||||
setRadio('status','Draft');
|
||||
loadMembers();
|
||||
loadLocations(); // CR-004: the option lists come from the server
|
||||
initWpNavDrawer();
|
||||
renderSavedList();
|
||||
positionSectionNav();
|
||||
|
||||
@@ -164,7 +164,21 @@
|
||||
<!-- Location is its own CR-006 section while still living inside General
|
||||
Information's grid. CR-004 gives it structured building/floor/sector
|
||||
fields of its own in wave 6; only this wrapper's contents change then. -->
|
||||
<div class="field" id="location-card"><label>Location</label><input type="text" id="wp_location" placeholder="building / level / sector / room"></div>
|
||||
<!-- CR-004. Three dependent dropdowns off the project's own taxonomy
|
||||
(CR-005 / T5.4), not free text: the values are what CR-018 rolls cost
|
||||
up by, and a rollup keyed on what somebody typed is not a rollup.
|
||||
wp_location survives as a hidden field so a package written before
|
||||
this keeps what it said — CLAUDE.md, removals are hidden not deleted. -->
|
||||
<div class="field" id="location-card">
|
||||
<label for="wp_building">Location<span class="help-tip" data-tip="Building, floor and sector come from this project's own location list, configured on step 11 of the SOP. Cost and progress roll up by these, which is why they are picked rather than typed.">i</span></label>
|
||||
<div class="loc-picker">
|
||||
<select id="wp_building" aria-label="Building"></select>
|
||||
<select id="wp_floor" aria-label="Floor"></select>
|
||||
<select id="wp_sector" aria-label="Sector"></select>
|
||||
</div>
|
||||
<div class="field-hint" id="wp_location_note"></div>
|
||||
<input type="hidden" id="wp_location">
|
||||
</div>
|
||||
<!-- CR-002 hides these two rather than deleting them: the columns, the model
|
||||
and every value already captured stay exactly as they are, and another
|
||||
project can turn them back on without a code change. The ids are what
|
||||
|
||||
@@ -936,6 +936,21 @@
|
||||
color:var(--accent-amber); border-color:var(--wp-status-warning-border-a);
|
||||
background:var(--accent-amber-dim);
|
||||
}
|
||||
/* CR-004: three dependent dropdowns where a free-text box used to be. They wrap
|
||||
rather than shrink — three selects squeezed onto one 390px line are three
|
||||
controls nobody can read the options of. */
|
||||
/* CR-018: one table per level, each adding up to the project on its own. */
|
||||
.loc-rollup { margin-top:12px; }
|
||||
.loc-rollup .dash-table { margin-top:4px; }
|
||||
/* The unassigned row and the total row are marked structurally, not only by
|
||||
colour: one is italic, the other bold with a rule above it. */
|
||||
.dash-table tr.loc-unassigned td { font-style:italic; color:var(--text-muted); }
|
||||
.dash-table tr.loc-total td { font-weight:700; border-top:2px solid var(--border-strong); }
|
||||
|
||||
.loc-picker { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.loc-picker select { flex:1 1 120px; min-width:0; }
|
||||
.loc-picker select:disabled { opacity:.55; cursor:not-allowed; }
|
||||
|
||||
.prio-urgent {
|
||||
color:var(--wp-btn-danger-fill-fg); border-color:var(--red); background:var(--red);
|
||||
}
|
||||
|
||||
@@ -1731,18 +1731,69 @@ PROGRESS_WEIGHT = {
|
||||
# when it does, only this tuple and the keys inside each group change — the response
|
||||
# shape does not, which is what T4.1 means by "can be grouped by location without a
|
||||
# schema change". CR-018's rollup consumes `by_location.groups` either way.
|
||||
LOCATION_DIMENSIONS = ("location",)
|
||||
# CR-004 landed at T6.3, so this is now what it was designed to become. T4.1's
|
||||
# note said "only this tuple and the keys inside each group change — the response
|
||||
# shape does not", and that held: CR-018's rollup consumes `by_location.groups`
|
||||
# exactly as it did when there was one free-text dimension.
|
||||
LOCATION_DIMENSIONS = ("building", "floor", "sector")
|
||||
|
||||
# A package with no location is not dropped from the rollup. `CR-018`'s own
|
||||
# done-when says so, and the reason is arithmetic: a group set that silently
|
||||
# omits the unlocated packages does not add up to the project total, and a rollup
|
||||
# that does not reconcile is worse than no rollup.
|
||||
LOCATION_UNSET = "(unassigned)"
|
||||
|
||||
|
||||
def _location_key(data: dict) -> dict:
|
||||
"""The location dimensions of one package, as a dict keyed by dimension name.
|
||||
|
||||
Structured fields win when they exist, so this keeps working unchanged the day
|
||||
CR-004 lands; until then it falls back to the free-text field."""
|
||||
structured = {d: (data.get(d) or "").strip() for d in ("building", "floor", "sector")}
|
||||
Structured fields win; the free-text `location` a package captured before
|
||||
CR-004 is kept as its own dimension value so those packages group together
|
||||
under what they actually said rather than all collapsing into one bucket."""
|
||||
structured = {d: (data.get(d) or "").strip() for d in LOCATION_DIMENSIONS}
|
||||
if any(structured.values()):
|
||||
return {k: v or "(unset)" for k, v in structured.items()}
|
||||
return {"location": (data.get("location") or "").strip() or "(unset)"}
|
||||
return {k: v or LOCATION_UNSET for k, v in structured.items()}
|
||||
legacy = (data.get("location") or "").strip()
|
||||
if legacy:
|
||||
# One dimension deep, deliberately: free text is not a hierarchy and
|
||||
# pretending it is would put "FAB / LVL 1" under a building called
|
||||
# "FAB / LVL 1".
|
||||
return {"building": legacy, "floor": LOCATION_UNSET, "sector": LOCATION_UNSET}
|
||||
return {d: LOCATION_UNSET for d in LOCATION_DIMENSIONS}
|
||||
|
||||
|
||||
def _location_levels(loc_groups: dict) -> dict:
|
||||
"""Totals at each level of the hierarchy, not only at the leaf.
|
||||
|
||||
`by_location.groups` is one row per distinct (building, floor, sector). The
|
||||
question CR-018 asks — "what is on floor 2" — is a level above that, and
|
||||
every level has to reconcile against the project total or the rollup is
|
||||
decoration. Each level therefore sums EVERY package, including the ones whose
|
||||
value at that level is unassigned."""
|
||||
out: dict[str, list] = {}
|
||||
for dim in LOCATION_DIMENSIONS:
|
||||
buckets: dict[str, dict] = {}
|
||||
for g in loc_groups.values():
|
||||
# Grouped by the value AT THIS LEVEL alone, not by the tuple of levels
|
||||
# above it. Each stored value is already a full path — a floor is
|
||||
# `B-ONE/L1`, not `L1` — so it carries its own ancestry and is unique
|
||||
# across buildings without being re-qualified. Only the unassigned
|
||||
# bucket is shared, which is what it should be: "these have no floor
|
||||
# recorded" is one answer, not one answer per building.
|
||||
path = g["key"].get(dim, LOCATION_UNSET) or LOCATION_UNSET
|
||||
slot = buckets.setdefault(path, {
|
||||
"dimension": dim,
|
||||
"path": path,
|
||||
"total": 0, "release_ready": 0, "on_hold": 0, "overdue": 0,
|
||||
"est_hours": 0.0, "actual_hours": 0.0,
|
||||
})
|
||||
for f in ("total", "release_ready", "on_hold", "overdue", "est_hours", "actual_hours"):
|
||||
slot[f] += g[f]
|
||||
out[dim] = [
|
||||
{**b, "est_hours": round(b["est_hours"]), "actual_hours": round(b["actual_hours"])}
|
||||
for b in sorted(buckets.values(), key=lambda b: b["path"])
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/wps/metrics")
|
||||
@@ -1833,7 +1884,8 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
||||
key = _location_key(data)
|
||||
kt = tuple(key.get(d, "(unset)") for d in LOCATION_DIMENSIONS) if len(key) == len(LOCATION_DIMENSIONS) else tuple(sorted(key.items()))
|
||||
slot = loc_groups.setdefault(kt, {"key": key, "total": 0, "release_ready": 0,
|
||||
"on_hold": 0, "overdue": 0, "by_status": {}})
|
||||
"on_hold": 0, "overdue": 0, "by_status": {},
|
||||
"est_hours": 0.0, "actual_hours": 0.0})
|
||||
slot["total"] += 1
|
||||
slot["by_status"][w.status] = slot["by_status"].get(w.status, 0) + 1
|
||||
if not blocked and w.status not in ("Closed", "Issue"):
|
||||
@@ -1842,6 +1894,17 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
||||
slot["on_hold"] += 1
|
||||
if is_overdue:
|
||||
slot["overdue"] += 1
|
||||
# CR-018: hours roll up along the same dimensions. Actual Hours is the one
|
||||
# CR-017 retained and is the reason that decision mattered — it is what
|
||||
# makes a floor's real cost visible.
|
||||
try:
|
||||
slot["est_hours"] += float(data.get("hours") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
slot["actual_hours"] += float(data.get("actualHrs") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
dimensions = list(LOCATION_DIMENSIONS)
|
||||
if loc_groups:
|
||||
@@ -1862,8 +1925,20 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
|
||||
],
|
||||
},
|
||||
"gating": sorted(gating, key=lambda g: (g["number"] or "", g["id"])),
|
||||
"by_location": {"dimensions": dimensions, "groups": sorted(
|
||||
loc_groups.values(), key=lambda g: tuple(str(v) for v in g["key"].values()))},
|
||||
"by_location": {
|
||||
"dimensions": dimensions,
|
||||
"unassigned_key": LOCATION_UNSET,
|
||||
"groups": [
|
||||
{**g, "est_hours": round(g["est_hours"]), "actual_hours": round(g["actual_hours"])}
|
||||
for g in sorted(loc_groups.values(),
|
||||
key=lambda g: tuple(str(v) for v in g["key"].values()))
|
||||
],
|
||||
# Rolled up one level at a time as well as by the full triple, because
|
||||
# "how many on floor 2" is the question CR-018 is actually about and
|
||||
# summing the leaf groups in the browser would be the same per-browser
|
||||
# arithmetic B4 removed.
|
||||
"levels": _location_levels(loc_groups),
|
||||
},
|
||||
"generated_at": models.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
508
tests/rollup_check.py
Normal file
508
tests/rollup_check.py
Normal file
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structured location, and the rollup that is the point of it — CR-004, CR-018.
|
||||
|
||||
X5 makes both of these blocking on `B4`: rollup by building, floor and sector
|
||||
cannot come from localStorage. So the two checks that matter most are the ones
|
||||
about where the numbers and the option lists come from, and they are made the
|
||||
way `aggregates_check.py` makes its own — by poisoning the cache and demanding
|
||||
the server's answer.
|
||||
|
||||
CR-004 three dependent dropdowns, populated from project configuration
|
||||
clearing a parent clears its children
|
||||
values persist as CODES; confirmed by reading what was stored
|
||||
the dashboard filters by each of the three
|
||||
a package referencing a deactivated value still renders
|
||||
all option data comes from the server
|
||||
CR-018 the dashboard groups and totals by building, floor and sector
|
||||
totals reconcile against an unfiltered count
|
||||
Actual Hours rolls up along the same dimensions
|
||||
grouping is computed server-side
|
||||
packages with no location appear in an explicit unassigned group
|
||||
rather than vanishing
|
||||
|
||||
"Reconciles" is arithmetic, so it is checked as arithmetic: every level's rows
|
||||
must sum to the project total, including the unassigned row. A rollup that does
|
||||
not add up is decoration.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
# Not a real building. IMPLEMENTATION.md section 8 — the B100 list has not been
|
||||
# supplied, and a probe inventing one puts a guessed floor name in the repo just
|
||||
# as surely as the app would.
|
||||
TAXONOMY = "\n".join([
|
||||
"Probe Building One,Probe Level 1,Probe Sector A",
|
||||
"Probe Building One,Probe Level 1,Probe Sector B",
|
||||
"Probe Building One,Probe Level 2,Probe Sector A",
|
||||
"Probe Building Two,Probe Level 1,Probe Sector A",
|
||||
])
|
||||
|
||||
B1 = "PROBE-BUILDING-ONE"
|
||||
B2 = "PROBE-BUILDING-TWO"
|
||||
F11 = B1 + "/PROBE-LEVEL-1"
|
||||
F12 = B1 + "/PROBE-LEVEL-2"
|
||||
S1A = F11 + "/PROBE-SECTOR-A"
|
||||
S1B = F11 + "/PROBE-SECTOR-B"
|
||||
S2A = F12 + "/PROBE-SECTOR-A"
|
||||
|
||||
SOP_DATA = {
|
||||
"sop": {"meta": {"tool": "Work Package Configuration"},
|
||||
"project": {"name": "Job A", "number": "A-1"},
|
||||
"governance": {"disciplines": ["Electrical"], "woFormat": "WP##-[TYPE]"},
|
||||
"woTypes": [{"name": "Conduit Install", "enabled": True}]},
|
||||
"state": {"project": {"name": "Job A", "number": "A-1", "client": "Internal QA",
|
||||
"division": "Internal", "site": "QA Lab"},
|
||||
"team": {"pm": "", "apm": "", "cm": "", "qm": ""},
|
||||
"teamIds": {"pm": "", "apm": "", "cm": "", "qm": ""}, "teamMembers": [],
|
||||
"signoffRoles": [{"role": "Superintendent", "name": ""}],
|
||||
"wpTypes": [{"name": "Conduit Install", "enabled": True}],
|
||||
"governance": {"woformat": "WP##-[TYPE]", "wosize": "", "issuance": [],
|
||||
"disciplines": ["Electrical"], "discMode": "choice",
|
||||
"instanceSuffix": "letter", "sizeHoursMax": ""},
|
||||
"quality": {"qcreq": "Yes", "photo": "", "hold": ""},
|
||||
"platforms": {"tracking": "CxAlloy", "commissioning": "CxAlloy",
|
||||
"trackingUrl": "", "commissioningUrl": ""},
|
||||
"constraints": [], "sequence": [], "sources": []},
|
||||
}
|
||||
|
||||
# Six packages: four located, one with only a building, one with nothing at all.
|
||||
# Hours are distinct primes so a mis-summed total cannot land on the right number
|
||||
# by luck.
|
||||
WPS = [
|
||||
("wpL1", "WPL01", {"building": B1, "floor": F11, "sector": S1A,
|
||||
"hours": "2", "actualHrs": "3", "constraints": []}),
|
||||
("wpL2", "WPL02", {"building": B1, "floor": F11, "sector": S1B,
|
||||
"hours": "5", "actualHrs": "7", "constraints": []}),
|
||||
("wpL3", "WPL03", {"building": B1, "floor": F12, "sector": S2A,
|
||||
"hours": "11", "actualHrs": "13", "constraints": []}),
|
||||
("wpL4", "WPL04", {"building": B2, "floor": B2 + "/PROBE-LEVEL-1",
|
||||
"sector": B2 + "/PROBE-LEVEL-1/PROBE-SECTOR-A",
|
||||
"hours": "17", "actualHrs": "19", "constraints": []}),
|
||||
("wpL5", "WPL05", {"building": B1, "hours": "23", "actualHrs": "29",
|
||||
"constraints": []}), # building only, no floor
|
||||
("wpL6", "WPL06", {"hours": "31", "actualHrs": "37", "constraints": []}), # nothing
|
||||
]
|
||||
TOTAL = len(WPS)
|
||||
EST_TOTAL = 2 + 5 + 11 + 17 + 23 + 31
|
||||
ACT_TOTAL = 3 + 7 + 13 + 19 + 29 + 37
|
||||
|
||||
|
||||
def settle(seconds=1.2):
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
def js_errors(page):
|
||||
"""page.js_errors() minus two entries that are not this task's, and not
|
||||
errors:
|
||||
|
||||
/api/sops/latest 404 a project with no SOP answers 404 correctly, and the
|
||||
browser logs every 404 resource at error level
|
||||
beforeunload Chromium logs a refusal to show the unsaved-work
|
||||
prompt on a page with no user gesture. That is T4.3's
|
||||
guard working, it is present at wave 4 too (checked
|
||||
during T5.1 by capturing both sides), and it fires
|
||||
here because the probe navigates away from a form it
|
||||
has typed into."""
|
||||
skip = ("/api/sops/latest", "beforeunload")
|
||||
return [e for e in page.js_errors() if not any(s in e for s in skip)]
|
||||
|
||||
|
||||
def wait_creator(page, tries=40):
|
||||
for _ in range(tries):
|
||||
if page.eval("!!window.wpCreatorReady"):
|
||||
return True
|
||||
time.sleep(0.3)
|
||||
return False
|
||||
|
||||
|
||||
def seed_located(db_path):
|
||||
tok = seed(db_path)
|
||||
from server.db import SessionLocal
|
||||
from server import models
|
||||
with SessionLocal() as db:
|
||||
db.get(models.Sop, "sopA").data = SOP_DATA
|
||||
# The two fixture packages carry no location and would muddy the
|
||||
# arithmetic; this probe owns the whole set.
|
||||
for wid in ("wpA1", "wpA2"):
|
||||
row = db.get(models.WorkPackage, wid)
|
||||
if row:
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
for wid, num, data in WPS:
|
||||
db.add(models.WorkPackage(
|
||||
id=wid, project_id="projA", sop_id="sopA", number=num,
|
||||
subject=num.lower(), status="Draft", type="Conduit Install",
|
||||
data=dict(data, number=num, subject=num.lower(), status="Draft")))
|
||||
db.commit()
|
||||
return tok
|
||||
|
||||
|
||||
def api(page, method, path, body=None):
|
||||
js = """(() => {
|
||||
const opts = {method: %s, headers: {'Accept':'application/json'}};
|
||||
%s
|
||||
return fetch(%s, opts).then(r => r.text().then(t => JSON.stringify(
|
||||
{status: r.status, body: (() => { try { return JSON.parse(t); } catch (e) { return t; } })()})));
|
||||
})()""" % (
|
||||
json.dumps(method),
|
||||
("opts.headers['Content-Type']='application/json'; opts.body=%s;" % json.dumps(json.dumps(body)))
|
||||
if body is not None else "",
|
||||
json.dumps(path))
|
||||
return json.loads(page.eval(js))
|
||||
|
||||
|
||||
def open_creator(page, base, tok, query="?project=projA"):
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["root"])
|
||||
page.goto(base + "/wp-creation-index.html" + query)
|
||||
ok = wait_creator(page)
|
||||
settle(1.6)
|
||||
page.eval(STUB)
|
||||
return ok
|
||||
|
||||
|
||||
def sel_options(page, el_id):
|
||||
return json.loads(page.eval("""JSON.stringify((() => {
|
||||
const s = document.getElementById(%s);
|
||||
if (!s) return null;
|
||||
return {disabled: s.disabled, value: s.value,
|
||||
options: [...s.options].map(o => ({v: o.value, t: o.textContent.trim()}))};
|
||||
})())""" % json.dumps(el_id)))
|
||||
|
||||
|
||||
def run(page, base, tok):
|
||||
print("\nsetting up: import the taxonomy through the API this project uses")
|
||||
open_creator(page, base, tok)
|
||||
res = api(page, "POST", "/api/projects/projA/locations/import", {"text": TAXONOMY})
|
||||
chk("the taxonomy imports", res["status"] == 200 and len(res["body"]["created"]) == 9, res)
|
||||
|
||||
print("\nCR-004 (T6.3). Three dependent dropdowns, from the server")
|
||||
open_creator(page, base, tok)
|
||||
chk("the creator boots with no JavaScript error", not js_errors(page), js_errors(page))
|
||||
for level, el in (("building", "wp_building"), ("floor", "wp_floor"), ("sector", "wp_sector")):
|
||||
chk("%-8s renders as a <select>, not a text box" % level,
|
||||
page.eval("(document.getElementById(%s)||{}).tagName" % json.dumps(el)) == "SELECT")
|
||||
chk("the free-text box is gone from the form",
|
||||
page.eval("(document.getElementById('wp_location')||{}).type") == "hidden")
|
||||
|
||||
b = sel_options(page, "wp_building")
|
||||
chk("buildings come from the project's own configuration",
|
||||
[o["v"] for o in b["options"][1:]] == [B1, B2], b)
|
||||
chk("...showing their NAMES, storing their codes",
|
||||
[o["t"] for o in b["options"][1:]] == ["Probe Building One", "Probe Building Two"], b)
|
||||
chk("floor starts disabled, because nothing could sensibly be in it",
|
||||
sel_options(page, "wp_floor")["disabled"] is True)
|
||||
chk("...and so does sector", sel_options(page, "wp_sector")["disabled"] is True)
|
||||
|
||||
print(" dependent filtering")
|
||||
page.eval("""(() => {
|
||||
const s = document.getElementById('wp_building');
|
||||
s.value = %s; s.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
return true;
|
||||
})()""" % json.dumps(B1))
|
||||
settle(0.5)
|
||||
f = sel_options(page, "wp_floor")
|
||||
chk("choosing a building offers only its floors",
|
||||
[o["v"] for o in f["options"][1:]] == [F11, F12], f)
|
||||
chk("...and not the other building's",
|
||||
not [o for o in f["options"] if o["v"].startswith(B2)], f)
|
||||
page.eval("""(() => {
|
||||
const s = document.getElementById('wp_floor');
|
||||
s.value = %s; s.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
return true;
|
||||
})()""" % json.dumps(F11))
|
||||
settle(0.5)
|
||||
sec = sel_options(page, "wp_sector")
|
||||
chk("choosing a floor offers only its sectors",
|
||||
[o["v"] for o in sec["options"][1:]] == [S1A, S1B], sec)
|
||||
|
||||
print(" clearing a parent clears its children")
|
||||
page.eval("""(() => {
|
||||
const s = document.getElementById('wp_sector');
|
||||
s.value = %s; s.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
const b = document.getElementById('wp_building');
|
||||
b.value = ''; b.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
return true;
|
||||
})()""" % json.dumps(S1A))
|
||||
settle(0.5)
|
||||
chk("clearing the building clears the floor",
|
||||
page.eval("document.getElementById('wp_floor').value") == "")
|
||||
chk("...and the sector", page.eval("document.getElementById('wp_sector').value") == "")
|
||||
chk("...and both are disabled again, not left offering stale options",
|
||||
sel_options(page, "wp_floor")["disabled"] is True
|
||||
and sel_options(page, "wp_sector")["disabled"] is True)
|
||||
|
||||
print(" values persist as codes")
|
||||
stored = json.loads(page.eval("""(() => {
|
||||
const set = (id, v) => { const s = document.getElementById(id);
|
||||
s.value = v; s.dispatchEvent(new Event('change', {bubbles:true})); };
|
||||
set('wp_building', %s); set('wp_floor', %s); set('wp_sector', %s);
|
||||
const out = collectPackage();
|
||||
return JSON.stringify({building: out.building, floor: out.floor, sector: out.sector});
|
||||
})()""" % (json.dumps(B1), json.dumps(F11), json.dumps(S1B))))
|
||||
chk("what is stored is codes, not display strings",
|
||||
stored == {"building": B1, "floor": F11, "sector": S1B}, stored)
|
||||
chk("...and no display string leaked into any of them",
|
||||
not any("Probe Building One" == v for v in stored.values()), stored)
|
||||
|
||||
print(" a deactivated value still renders on a package that references it")
|
||||
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
||||
sec_b = [n for n in nodes if n["path"] == S1B][0]
|
||||
api(page, "PATCH", "/api/projects/projA/locations/" + sec_b["id"], {"active": False})
|
||||
open_creator(page, base, tok)
|
||||
page.eval("""(() => {
|
||||
loadPackageIntoForm({id:'x', number:'WPX', subject:'deactivated probe',
|
||||
building: %s, floor: %s, sector: %s, constraints: []});
|
||||
return true;
|
||||
})()""" % (json.dumps(B1), json.dumps(F11), json.dumps(S1B)))
|
||||
settle(0.6)
|
||||
chk("the package keeps its deactivated sector rather than being blanked",
|
||||
page.eval("document.getElementById('wp_sector').value") == S1B,
|
||||
page.eval("document.getElementById('wp_sector').value"))
|
||||
chk("...and the option says it is no longer offered",
|
||||
"no longer offered" in json.dumps(sel_options(page, "wp_sector")),
|
||||
sel_options(page, "wp_sector"))
|
||||
chk("...and it is NOT offered on a fresh package",
|
||||
page.eval("""(() => {
|
||||
newPackage();
|
||||
const b = document.getElementById('wp_building');
|
||||
b.value = %s; b.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
const f = document.getElementById('wp_floor');
|
||||
f.value = %s; f.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
return [...document.getElementById('wp_sector').options].map(o => o.value)
|
||||
.indexOf(%s) < 0;
|
||||
})()""" % (json.dumps(B1), json.dumps(F11), json.dumps(S1B))))
|
||||
api(page, "PATCH", "/api/projects/projA/locations/" + sec_b["id"], {"active": True})
|
||||
|
||||
print(" all option data comes from the server")
|
||||
src = open(os.path.join(ROOT, "html", "wp-creation-app.js"), encoding="utf-8").read()
|
||||
start = src.find("// ── LOCATION (CR-004 / T6.3)")
|
||||
end = src.find("// ── SECTION TOGGLES", start)
|
||||
block = src[start:end]
|
||||
code = "\n".join(ln for ln in block.splitlines() if not ln.strip().startswith("//"))
|
||||
chk("the location block exists", bool(block), [start, end])
|
||||
chk("...and never reads localStorage (X5)", "localStorage" not in code,
|
||||
[ln for ln in code.splitlines() if "localStorage" in ln][:2])
|
||||
chk("...it fetches the list from the locations endpoint",
|
||||
"/locations?include_inactive=true" in code)
|
||||
page.eval("""(() => {
|
||||
for (const k of ['wp_locations','wp_locations__projA','wp_location_list'])
|
||||
localStorage.setItem(k, JSON.stringify([{path:'FAKE', name:'Fake Building',
|
||||
level:'building', active:true}]));
|
||||
return true;
|
||||
})()""")
|
||||
open_creator(page, base, tok)
|
||||
chk("a poisoned cache puts nothing in the dropdown",
|
||||
"FAKE" not in json.dumps(sel_options(page, "wp_building")),
|
||||
sel_options(page, "wp_building"))
|
||||
page.eval("localStorage.removeItem('wp_locations'); "
|
||||
"localStorage.removeItem('wp_locations__projA'); "
|
||||
"localStorage.removeItem('wp_location_list'); true")
|
||||
|
||||
print("\nCR-018 (T6.4). The rollup, and whether it adds up")
|
||||
m = api(page, "GET", "/api/wps/metrics?project_id=projA")["body"]
|
||||
chk("the endpoint reports the six packages", m["total"] == TOTAL, m["total"])
|
||||
chk("the location dimensions are building, floor and sector",
|
||||
m["by_location"]["dimensions"] == ["building", "floor", "sector"],
|
||||
m["by_location"]["dimensions"])
|
||||
chk("...and it names the unassigned bucket rather than leaving it a magic string",
|
||||
m["by_location"].get("unassigned_key") == "(unassigned)", m["by_location"].get("unassigned_key"))
|
||||
levels = m["by_location"]["levels"]
|
||||
chk("every level is rolled up, not just the leaf",
|
||||
sorted(levels) == ["building", "floor", "sector"], sorted(levels))
|
||||
|
||||
for dim in ("building", "floor", "sector"):
|
||||
rows = levels[dim]
|
||||
chk("%-8s rows sum to the project total (%d)" % (dim, TOTAL),
|
||||
sum(r["total"] for r in rows) == TOTAL,
|
||||
[(r["path"], r["total"]) for r in rows])
|
||||
chk(" ...and its estimated hours to %d" % EST_TOTAL,
|
||||
sum(r["est_hours"] for r in rows) == EST_TOTAL,
|
||||
[(r["path"], r["est_hours"]) for r in rows])
|
||||
chk(" ...and its ACTUAL hours to %d, the CR-017 field CR-018 needs" % ACT_TOTAL,
|
||||
sum(r["actual_hours"] for r in rows) == ACT_TOTAL,
|
||||
[(r["path"], r["actual_hours"]) for r in rows])
|
||||
chk(" ...and nothing vanished: an unassigned row is present",
|
||||
any(r["path"] == "(unassigned)" for r in rows), [r["path"] for r in rows])
|
||||
|
||||
b_rows = {r["path"]: r for r in levels["building"]}
|
||||
chk("building one holds four packages", b_rows[B1]["total"] == 4, b_rows.get(B1))
|
||||
chk("building two holds one", b_rows[B2]["total"] == 1, b_rows.get(B2))
|
||||
chk("...and the one with no location at all is its own row",
|
||||
b_rows["(unassigned)"]["total"] == 1, b_rows.get("(unassigned)"))
|
||||
f_rows = {r["path"]: r for r in levels["floor"]}
|
||||
chk("the package with a building but no floor lands in the floor-level unassigned row",
|
||||
f_rows["(unassigned)"]["total"] == 2, f_rows.get("(unassigned)"))
|
||||
chk("...which is the honest answer: one has no location, one has no floor",
|
||||
f_rows["(unassigned)"]["actual_hours"] == 29 + 37, f_rows.get("(unassigned)"))
|
||||
chk("floor 1 of building one holds two", f_rows[F11]["total"] == 2, f_rows.get(F11))
|
||||
chk("...with their hours, not somebody else's",
|
||||
f_rows[F11]["actual_hours"] == 3 + 7, f_rows.get(F11))
|
||||
|
||||
print(" the grouping is the SERVER's, not the browser's")
|
||||
open_creator(page, base, tok)
|
||||
page.eval("""(() => {
|
||||
// Nine fake packages, all in one made-up building. If the panel were summed
|
||||
// in the browser this is what it would show.
|
||||
savedPackages = Array.from({length: 9}, (_, i) => ({
|
||||
id: 'fake' + i, number: 'FAKE0' + i, subject: 'fake', status: 'Draft',
|
||||
building: 'FAKE-BUILDING', hours: '99', actualHrs: '99', constraints: []}));
|
||||
saveStore(); showDashboard(); return true;
|
||||
})()""")
|
||||
for _ in range(30):
|
||||
if page.eval("!!document.querySelector('.loc-rollup')"):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
settle(1.2)
|
||||
panel = page.eval("""(() => {
|
||||
const p = [...document.querySelectorAll('.dash-panel')].find(x => {
|
||||
const t = x.querySelector('.dash-panel-title');
|
||||
return t && /^By location/.test(t.textContent.trim());
|
||||
});
|
||||
return p ? p.textContent : '';
|
||||
})()""")
|
||||
chk("the rollup panel renders", bool(panel and panel.strip()), (panel or "")[:80])
|
||||
chk("...and shows none of the nine fake packages", "FAKE-BUILDING" not in panel
|
||||
and "Fake" not in panel, panel[:200])
|
||||
chk("...it shows the server's buildings instead", "Probe Building One" in panel, panel[:200])
|
||||
chk("...and an explicit Unassigned row, not a gap", "Unassigned" in panel, panel[:300])
|
||||
chk("...with the totals the server computed",
|
||||
re.search(r"All buildings\s*%d" % TOTAL, panel.replace("\n", " ")) is not None,
|
||||
[ln for ln in panel.splitlines() if "All building" in ln][:1])
|
||||
|
||||
print(" the dashboard filters by each of the three")
|
||||
page.eval("""(() => {
|
||||
savedPackages = %s; saveStore(); renderDashboard(); return true;
|
||||
})()""" % json.dumps([dict(d, id=i, number=n, subject=n.lower(), type='Conduit Install')
|
||||
for i, n, d in WPS]))
|
||||
settle(0.8)
|
||||
|
||||
def board_rows():
|
||||
return json.loads(page.eval("""JSON.stringify((() => {
|
||||
const panel = [...document.querySelectorAll('.dash-panel')].find(p => {
|
||||
const t = p.querySelector('.dash-panel-title');
|
||||
return t && /^Work packages/.test(t.textContent.trim());
|
||||
});
|
||||
return [...panel.querySelectorAll('tbody tr')]
|
||||
.map(r => (r.cells[0]||{}).textContent || '').map(s => s.trim());
|
||||
})())"""))
|
||||
|
||||
chk("unfiltered, the board shows all six", len(board_rows()) == TOTAL, board_rows())
|
||||
page.eval("dashSetLocationFilter('building', %s)" % json.dumps(B1))
|
||||
settle(0.6)
|
||||
chk("filtering by building narrows to its four", sorted(board_rows())
|
||||
== ["WPL01", "WPL02", "WPL03", "WPL05"], board_rows())
|
||||
page.eval("dashSetLocationFilter('floor', %s)" % json.dumps(F11))
|
||||
settle(0.6)
|
||||
chk("...then by floor, to its two", sorted(board_rows()) == ["WPL01", "WPL02"], board_rows())
|
||||
page.eval("dashSetLocationFilter('sector', %s)" % json.dumps(S1A))
|
||||
settle(0.6)
|
||||
chk("...then by sector, to one", board_rows() == ["WPL01"], board_rows())
|
||||
page.eval("dashSetLocationFilter('building', '')")
|
||||
settle(0.6)
|
||||
chk("clearing the building clears the floor and sector filters with it",
|
||||
len(board_rows()) == TOTAL, board_rows())
|
||||
|
||||
chk("no native dialog was opened anywhere in this flow",
|
||||
not json.loads(page.eval("JSON.stringify(window.__dialogs||[])")),
|
||||
page.eval("JSON.stringify(window.__dialogs||[])"))
|
||||
|
||||
print("\n both widths")
|
||||
for w, label in ((390, "390px"), (1440, "1440px")):
|
||||
page.viewport(w, 900, mobile=(w == 390))
|
||||
open_creator(page, base, tok)
|
||||
chk("%s: the three dropdowns all render" % label, page.eval("""(() => {
|
||||
return ['wp_building','wp_floor','wp_sector'].every(id => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
});
|
||||
})()"""))
|
||||
chk("%s: and none of them is clipped by the viewport" % label, page.eval("""(() => {
|
||||
return ['wp_building','wp_floor','wp_sector'].every(id => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
return r.left >= -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-rollup-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
try:
|
||||
tok = seed_located(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("\nStructured location and rollup — CR-004 / CR-018\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 — picked not typed, and the totals add up.", "32") + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user