T5.4 - CR-005: a per-project location taxonomy, stored as codes

CLAUDE.md lists CR-005 among the change requests that get "silently half-built if
you treat them as frontend-only". This is the server half and the wizard half
together: a new table, four routes, an Alembic revision, and step 11.

CODES, NOT DISPLAY STRINGS, because CR-018 rolls cost up by these values and a
rollup keyed on a label breaks the day somebody fixes a typo in it. Two columns
carry that: `code` is a node's own slug, derived once at import and never
recomputed; `path` is the full slug path, unique per project, and is what a work
package will store. Renaming a value changes `name` alone - the probe renames a
floor and demands its path comes back byte-identical, with its children's paths
intact.

DEACTIVATE, NEVER DELETE. There is no DELETE route, and the probe checks for its
absence (405) rather than trusting that nobody added one. Deactivating hides a
value from new work packages and cascades DOWN, because a floor nobody can pick
must not keep offering its sectors. Reactivating walks UP only - a sector may
have been switched off for its own reasons, and silently resurrecting it would
undo a decision nobody made twice. That asymmetry is deliberate and is pinned by
a named check so it does not get "fixed" into a surprise.

Import reports rather than merges. Rejected rows come back with the SOURCE line
number and a reason; duplicates are listed as duplicates, separated into "already
in this project" and "already on line N of this import". Reusing a parent is not
a duplicate - B1/L2/1P and B1/L2/2P share a building and a floor by design, and
only the full path repeating counts. Re-importing a deactivated value brings the
same row back rather than creating a second one; the probe checks the id.

One parser, on the server. A CSV is read in the browser and posted as text
exactly as a paste is, so "what does a blank column mean" has one answer.
Comma, semicolon and tab all work - a paste out of a spreadsheet is tab
separated and a saved CSV is not, and which one somebody has is a question the
machine can answer.

No guessed floor names. IMPLEMENTATION.md section 8 says the B100 list has not
been supplied. The seeded sample has "Sample" inside every string, and the probe
greps html/ and server/ for a location-shaped assignment containing any of the
review's real names.

  server/models.py                    LocationNode
  server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
  server/app.py                       GET/POST/PATCH + import, parser, slug
  html/work-package-suite.html        step 11, an 11th rail button
  html/work-package-suite-app.js      the step's logic; LAST_STEP replaces 10
  html/work-package-suite-styles.css  the list, the report
  html/theme-light.css                .field-error, now declared once
  tests/locations_check.py            new - 58 checks
  tests/stepper_check.py              STEP_COUNT 10 -> 11

Done when
  [x] CSV upload and paste both work and report rejected rows with reasons
  [x] duplicates are detected and reported rather than silently merged
  [x] values are editable after import - rename, add, deactivate
  [x] deactivating hides it from new work packages; an existing package
      referencing it still resolves, because the row is retained
  [x] values are stored as codes suitable for grouping
  [x] no guessed real-world floor names exist anywhere in the code

Two decisions worth disagreeing with

  Step 11, appended, not step 2, inserted. Locations belong beside Project by
  subject. Renumbering 2-10 would touch every sop-step-N id, every
  collectStepData case, every gate key and the analytics history - a large
  silent-mismatch surface for an ordering change. The count now lives in one
  place (LAST_STEP), so reordering later is cheap.

  Any project member may edit the list, not only a Project Admin. It matches how
  the SOP baseline itself is authored: the Project Admin gate is on CHANGING a
  completed SOP, not on writing one. If the location list should be tighter than
  the SOP it belongs to, that is a product call.

Verified one at a time
  locations_check  58/58  new
  stepper_check    70/70  (11 steps)
  browser_check    71/71
  a11y             22/22  sop now rings 38 focusable elements
  url_state        23/23
  autosave         34/34
  aggregates       16/16
  pipeline         43/43
  launcher         58/58
  f_items          F1-F5 FIXED, F6 REPRODUCES (T7.2)
  alembic          upgrade / downgrade / upgrade all clean on a throwaway SQLite
                   file, and the migrated schema matches Base.metadata.create_all
                   column for column - dev auto-creates and production migrates,
                   so a divergence between the two is invisible until it ships

.field-error was declared in two page sheets by the end of T5.2 and would have
been three by T5.8, so it moved to theme-light.css. No colour literal added
anywhere: still 0 across all page sheets and inline blocks.

Question for the PR, per CLAUDE.md: the levels are fixed at building / floor /
sector. Micron's floors behave like buildings, which this handles by letting a
project use whichever levels it needs - but a job that wants a fourth level, or
different names for the three, cannot say so. Whether that is worth a
per-project level vocabulary is a product question; the schema would take it
without a migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:06:27 -05:00
parent 6088ef17e8
commit 2081c1ad3c
11 changed files with 1397 additions and 25 deletions

View File

@@ -21,6 +21,14 @@ roughly 11,900 lines" — the line count is right, the other two are not. See D1
| `html/login.html` | login | 163 | `theme-light.css` | `login.js` | neither | | `html/login.html` | login | 163 | `theme-light.css` | `login.js` | neither |
| `html/index.html` | launcher | 703 | `theme-light.css`, `wp-chrome.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, *(inline 418700)*, `wp-chrome` | neither | | `html/index.html` | launcher | 703 | `theme-light.css`, `wp-chrome.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, *(inline 418700)*, `wp-chrome` | neither |
| `html/work-package-suite.html` | SOP wizard | 437 | `theme-light.css`, `wp-chrome.css`, `work-package-suite-styles.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, `work-package-suite-app`, `wp-chrome` | **hosts** | | `html/work-package-suite.html` | SOP wizard | 437 | `theme-light.css`, `wp-chrome.css`, `work-package-suite-styles.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, `work-package-suite-app`, `wp-chrome` | **hosts** |
> **The SOP wizard has 11 steps, not 10, since `T5.4`.** `CR-005`'s location list was
> **appended** as step 11 rather than inserted beside Project, where it belongs by
> subject: renumbering 210 would touch every `sop-step-N` id, every `collectStepData`
> case, every gate key and the analytics history, for an ordering change. The count
> lives in one place — `LAST_STEP` in `work-package-suite-app.js` — and reordering is
> cheap once nothing depends on the numbers. `tests/stepper_check.py` names it
> `STEP_COUNT` for the same reason.
| `html/wp-creation-index.html` | creator | 401 | `theme-light.css`, `wp-creation-styles.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, `wp-creation-app` | **child** | | `html/wp-creation-index.html` | creator | 401 | `theme-light.css`, `wp-creation-styles.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, `wp-creation-app` | **child** |
| `html/admin.html` | admin | 219 | `theme-light.css`, `wp-chrome.css`, `console.css`, `wp-sidenav.css` | `auth-guard`, `wp-format`, `console-util`, `admin`, `wp-chrome`, `wp-sidenav` | neither | | `html/admin.html` | admin | 219 | `theme-light.css`, `wp-chrome.css`, `console.css`, `wp-sidenav.css` | `auth-guard`, `wp-format`, `console-util`, `admin`, `wp-chrome`, `wp-sidenav` | neither |
| `html/users.html` | directory/users | 106 | `theme-light.css`, `wp-chrome.css`, `console.css`, `wp-sidenav.css` | `auth-guard`, `wp-format`, `console-util`, `users`, `wp-chrome`, `wp-sidenav` | neither | | `html/users.html` | directory/users | 106 | `theme-light.css`, `wp-chrome.css`, `console.css`, `wp-sidenav.css` | `auth-guard`, `wp-format`, `console-util`, `users`, `wp-chrome`, `wp-sidenav` | neither |
@@ -226,6 +234,7 @@ Wave 5 added more, for the same reason:
python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard operable 70 checks python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard operable 70 checks
python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks
python tests/pipeline_check.py # B4 surface — server counts, shareable links 43 checks python tests/pipeline_check.py # B4 surface — server counts, shareable links 43 checks
python tests/locations_check.py # CR-005 — codes not labels, nothing deleted 58 checks
``` ```
`pipeline_check.py` reads the dashboard's state out of the **iframe's DOM**, not its `pipeline_check.py` reads the dashboard's state out of the **iframe's DOM**, not its

View File

@@ -311,11 +311,8 @@
BL-014 as controls falling back to the UA focus ring; two of that entry's BL-014 as controls falling back to the UA focus ring; two of that entry's
four sites no longer exist. */ four sites no longer exist. */
/* An inline error at the field it belongs to, rather than a native dialog that /* .field-error — the inline error beside a field — is declared once, in
names no field and highlights nothing (C1, and the pattern T5.8 uses in the theme-light.css, because three surfaces in wave 5 grew one. */
wizard). :empty so the element can sit in the markup and announce on change. */
.field-error { color: var(--cds-text-error); font-size: 12px; font-weight: 600; margin-top: 0.3rem; }
.field-error:empty { display: none; }
/* PIPELINE STRIP — B4 surface (T5.3) /* PIPELINE STRIP — B4 surface (T5.3)
Four counts, every one of them from /api/wps/metrics. There is deliberately Four counts, every one of them from /api/wps/metrics. There is deliberately

View File

@@ -508,6 +508,22 @@ input, textarea, select {
.wp-draft-retry:hover { background: var(--wp-btn-danger-soft-bg); } .wp-draft-retry:hover { background: var(--wp-btn-danger-soft-bg); }
.wp-draft-retry:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: -2px; } .wp-draft-retry:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
/* An inline validation error, at the field it belongs to (C1).
----------------------------------------------------------------------------
Three surfaces grew one of these within wave 5 — the launcher's create-project
form (T5.2), the SOP wizard's location list (T5.4), and T5.8's step validation
— so it lives here rather than as three page-sheet rules that would drift.
The markup pairs it with aria-describedby and role="alert" on the element; the
:empty rule is what lets it sit in the page permanently and announce on change
rather than being created at the moment somebody needs to hear it. */
.field-error {
color: var(--cds-text-error);
font-size: 12px;
font-weight: 600;
margin-top: 4px;
}
.field-error:empty { display: none; }
/* ============================================================================ /* ============================================================================
FOCUS (S12 / T4.7) FOCUS (S12 / T4.7)
---------------------------------------------------------------------------- ----------------------------------------------------------------------------

View File

@@ -309,7 +309,7 @@ window.addEventListener('DOMContentLoaded',()=>{
else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true}); else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true});
else if(_deepLinkWp) switchTool('wp', {fromUrl:true}); else if(_deepLinkWp) switchTool('wp', {fromUrl:true});
const bootStep = parseInt(params.get('step'), 10); const bootStep = parseInt(params.get('step'), 10);
if(bootStep >= 1 && bootStep <= 10 && currentTool === 'sop') goToStep(bootStep, {fromUrl:true}); if(bootStep >= 1 && bootStep <= LAST_STEP && currentTool === 'sop') goToStep(bootStep, {fromUrl:true});
} }
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){ if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
ProjectData.pullProject(projId).then(afterPull).catch(afterPull); ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
@@ -439,7 +439,7 @@ function loadSampleData(){
// Switch to step 1. Every step now holds sample content, so the rail marks them // Switch to step 1. Every step now holds sample content, so the rail marks them
// all as visited — otherwise a fully populated wizard shows ten unstarted steps. // all as visited — otherwise a fully populated wizard shows ten unstarted steps.
for(let i = 1; i <= 10; i++) _visitedSteps.add(i); for(let i = 1; i <= LAST_STEP; i++) _visitedSteps.add(i);
currentStep = 1; currentStep = 1;
updateStepUI(); updateStepUI();
updateProjectDisplay(); updateProjectDisplay();
@@ -472,7 +472,7 @@ function restoreSavedSOP(){
repopulateForm(); repopulateForm();
// A completed SOP has been all the way through: the rail marks every step done // A completed SOP has been all the way through: the rail marks every step done
// rather than showing ten unstarted steps over a finished configuration. // rather than showing ten unstarted steps over a finished configuration.
for(let i = 1; i <= 10; i++) _visitedSteps.add(i); for(let i = 1; i <= LAST_STEP; i++) _visitedSteps.add(i);
if(typeof onSOPReady === 'function') onSOPReady(sop); if(typeof onSOPReady === 'function') onSOPReady(sop);
} }
@@ -1213,6 +1213,294 @@ function addSource(){
renderSources(); renderSources();
} }
// ── STEP 11: LOCATION LIST (CR-005) ───────────────────────────────────────────
// Server-backed throughout. CLAUDE.md: "If a task touches one of these and you
// find yourself writing to localStorage, you are building the wrong thing."
// CR-018 rolls cost up by these values, and a taxonomy each browser keeps its own
// copy of cannot be rolled up by anything.
//
// The list is fetched with include_inactive=true here, deliberately: this is the
// screen where you MANAGE the list, so a value somebody deactivated has to be
// visible in order to be brought back. The work package form asks for the default
// (active only), which is what makes deactivating hide it from new packages.
// Obviously fake, and labelled as such in the values themselves. IMPLEMENTATION.md
// section 8: the B100 floor and area list has not been supplied, and hardcoding a
// guess would put invented Micron floor names into the repository. "Sample" is in
// every string so nobody can mistake one for real data.
const LOCATION_SAMPLE = [
'Sample Building One,Sample Level 1,Sample Sector A',
'Sample Building One,Sample Level 1,Sample Sector B',
'Sample Building One,Sample Level 2,Sample Sector A',
'Sample Building Two,Sample Level 1,Sample Sector A',
].join('\n');
let _locNodes = [];
let _locLoaded = false;
function locProjectId(){
try {
const fromUrl = new URLSearchParams(window.location.search).get('project');
if(fromUrl) return fromUrl;
if(typeof ProjectData !== 'undefined' && ProjectData.getActiveId) return ProjectData.getActiveId() || '';
} catch(e){}
return '';
}
function locApi(suffix){
return '/api/projects/' + encodeURIComponent(locProjectId()) + '/locations' + (suffix || '');
}
function locEsc(v){ return escAttr(v); }
// Every message on this step goes through here so the role is decided in one
// place: a report that lost rows interrupts (T4.5), a clean one does not.
function locSay(html, isProblem){
const el = document.getElementById('loc-report');
if(!el) return;
el.setAttribute('role', isProblem ? 'alert' : 'status');
el.innerHTML = html || '';
el.classList.toggle('is-problem', !!isProblem);
}
function locSetAddError(msg){
const el = document.getElementById('loc-add-err');
if(el) el.textContent = msg || '';
const input = document.getElementById('loc-add-name');
if(input){
if(msg) input.setAttribute('aria-invalid', 'true');
else input.removeAttribute('aria-invalid');
}
}
function locLoad(force){
const tool = document.getElementById('loc-tool');
const warn = document.getElementById('loc-noproject');
const pid = locProjectId();
if(!pid){
if(tool) tool.style.display = 'none';
if(warn){
warn.style.display = '';
warn.textContent = 'Open this wizard from a project to configure its location list. '
+ 'The list is stored against the project on the server, not in this browser.';
}
return Promise.resolve();
}
if(tool) tool.style.display = '';
if(warn) warn.style.display = 'none';
if(_locLoaded && !force) return Promise.resolve();
return fetch(locApi('?include_inactive=true'), {headers:{'Accept':'application/json'}})
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(data => { _locNodes = data.nodes || []; _locLoaded = true; locRender(); })
.catch(err => {
_locLoaded = false;
locRender();
locSay('⚠ Could not load the location list — ' + locEsc((err && err.message) || 'offline')
+ '. It is stored on the server, so nothing local is shown in its place.', true);
});
}
function locRender(){
const list = document.getElementById('loc-list');
const count = document.getElementById('loc-count');
if(!list) return;
const active = _locNodes.filter(n => n.active);
if(count){
count.textContent = _locNodes.length
? `${active.length} value${active.length===1?'':'s'} in use`
+ (_locNodes.length > active.length ? `, ${_locNodes.length - active.length} deactivated` : '')
: '';
}
if(!_locNodes.length){
list.innerHTML = '<p class="field-hint">Nothing configured yet. Paste or upload a list above, '
+ 'or add values one at a time.</p>';
locRenderParentPicker();
return;
}
// Ordered by path from the server, so a plain walk is already the tree order.
list.innerHTML = _locNodes.map(n => {
const depth = (n.path.match(/\//g) || []).length;
return `<div class="loc-row loc-depth-${depth}${n.active ? '' : ' is-off'}" data-id="${locEsc(n.id)}">
<span class="loc-level">${locEsc(n.level)}</span>
<input class="loc-name" type="text" value="${locEsc(n.name)}"
aria-label="Name for ${locEsc(n.path)}" data-id="${locEsc(n.id)}">
<code class="loc-code" title="The stable code cost rolls up by. Renaming does not change it.">${locEsc(n.path)}</code>
<label class="loc-toggle"><input type="checkbox" class="loc-active" data-id="${locEsc(n.id)}"
${n.active ? 'checked' : ''}><span>In use</span></label>
</div>`;
}).join('');
locRenderParentPicker();
}
// What a hand-added value can hang off. A building has nothing above it, so the
// empty option is a real choice rather than a prompt.
function locRenderParentPicker(){
const sel = document.getElementById('loc-add-parent');
if(!sel) return;
const keep = sel.value;
const opts = ['<option value="">(nothing — add a building)</option>'].concat(
_locNodes.filter(n => n.active && n.level !== 'sector')
.map(n => `<option value="${locEsc(n.id)}">${locEsc(n.path)} — adds a ${n.level === 'building' ? 'floor' : 'sector'}</option>`)
);
sel.innerHTML = opts.join('');
if(keep && Array.from(sel.options).some(o => o.value === keep)) sel.value = keep;
}
function locReport(res){
const bits = [];
const problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0;
const verb = res.dry_run ? 'would be added' : 'added';
bits.push(`<p class="loc-report-line"><strong>${res.read} row${res.read===1?'':'s'} read.</strong> `
+ `${(res.created||[]).length} value${(res.created||[]).length===1?'':'s'} ${verb}`
+ ((res.reactivated||[]).length ? `, ${res.reactivated.length} brought back into use` : '')
+ `.</p>`);
const rowList = (title, rows, fmt) => {
if(!rows || !rows.length) return '';
return `<div class="loc-report-group"><div class="loc-report-title">${locEsc(title)} (${rows.length})</div>`
+ '<ul class="loc-report-list">' + rows.map(fmt).join('') + '</ul></div>';
};
bits.push(rowList('Rejected', res.rejected,
r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`));
bits.push(rowList('Duplicates, not merged', res.duplicates,
r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.path)}</code> — ${locEsc(r.reason)}</li>`));
if(!problem && !(res.created||[]).length && !(res.reactivated||[]).length){
bits.push('<p class="loc-report-line">Nothing to do — every row is already on this project.</p>');
}
locSay(bits.join(''), problem);
}
function locImport(dryRun){
const text = (document.getElementById('loc-paste') || {}).value || '';
if(!text.trim()){
locSay('Paste some rows or choose a CSV file first.', true);
return;
}
if(!locProjectId()){ locLoad(); return; }
locSay('Checking…', false);
fetch(locApi('/import'), {
method: 'POST', headers: {'Content-Type':'application/json','Accept':'application/json'},
body: JSON.stringify({text: text, dry_run: !!dryRun}),
})
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
.then(res => {
if(!res.ok){
locSay('⚠ Import refused — ' + locEsc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
return;
}
locReport(res.body);
if(!dryRun) return locLoad(true);
})
.catch(err => locSay('⚠ Could not reach the server — ' + locEsc((err && err.message) || 'offline')
+ '. Nothing was imported.', true));
}
function locAdd(){
const nameEl = document.getElementById('loc-add-name');
const parentEl = document.getElementById('loc-add-parent');
const name = ((nameEl || {}).value || '').trim();
if(!name){
locSetAddError('Enter a name for the value you are adding.');
if(nameEl) nameEl.focus();
return;
}
locSetAddError('');
const parentId = (parentEl || {}).value || '';
const parent = _locNodes.find(n => n.id === parentId);
const level = !parent ? 'building' : (parent.level === 'building' ? 'floor' : 'sector');
fetch(locApi(''), {
method: 'POST', headers: {'Content-Type':'application/json','Accept':'application/json'},
body: JSON.stringify({level: level, parent_id: parentId || null, name: name}),
})
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
.then(res => {
if(!res.ok){
locSetAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
if(nameEl) nameEl.focus();
return;
}
if(nameEl) nameEl.value = '';
locSay(`Added <code>${locEsc(res.body.path)}</code>.`, false);
return locLoad(true);
})
.catch(err => locSetAddError('Could not reach the server — ' + ((err && err.message) || 'offline')));
}
function locPatch(id, patch, describe){
return fetch(locApi('/' + encodeURIComponent(id)), {
method: 'PATCH', headers: {'Content-Type':'application/json','Accept':'application/json'},
body: JSON.stringify(patch),
})
.then(r => r.json().then(j => ({ok: r.ok, status: r.status, body: j})))
.then(res => {
if(!res.ok){
locSay('⚠ ' + locEsc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
return locLoad(true);
}
locSay(describe(res.body), false);
return locLoad(true);
})
.catch(err => locSay('⚠ Could not reach the server — ' + locEsc((err && err.message) || 'offline'), true));
}
document.addEventListener('DOMContentLoaded', function(){
const paste = document.getElementById('loc-paste');
const file = document.getElementById('loc-file');
const btn = id => document.getElementById(id);
if(btn('loc-file-btn')) btn('loc-file-btn').addEventListener('click', () => file && file.click());
if(file) file.addEventListener('change', function(ev){
const f = ev.target.files && ev.target.files[0];
if(!f) return;
const reader = new FileReader();
reader.onload = () => {
// One parser, on the server. Reading the file here and posting its text is
// what stops "what does a blank column mean" having two answers.
if(paste) paste.value = String(reader.result || '');
locSay(`Read <strong>${locEsc(f.name)}</strong>. Check it, then import.`, false);
};
reader.onerror = () => locSay('⚠ Could not read that file.', true);
reader.readAsText(f);
ev.target.value = '';
});
if(btn('loc-check-btn')) btn('loc-check-btn').addEventListener('click', () => locImport(true));
if(btn('loc-import-btn')) btn('loc-import-btn').addEventListener('click', () => locImport(false));
if(btn('loc-sample-btn')) btn('loc-sample-btn').addEventListener('click', function(){
if(paste) paste.value = LOCATION_SAMPLE;
locSay('Sample values loaded into the box — obviously fake, and safe to import '
+ 'while the real list is being collected. Check them, then import.', false);
});
if(btn('loc-add-btn')) btn('loc-add-btn').addEventListener('click', locAdd);
const addName = btn('loc-add-name');
if(addName) addName.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); locAdd(); } });
const list = document.getElementById('loc-list');
if(list){
// Rename on blur, not on every keystroke: a rename is a write, and one per
// character would be a hundred writes for a hundred-character name.
list.addEventListener('change', function(e){
const t = e.target;
if(!t) return;
if(t.classList.contains('loc-active')){
const node = _locNodes.find(n => n.id === t.dataset.id);
locPatch(t.dataset.id, {active: t.checked},
b => b.active
? `<code>${locEsc(b.path)}</code> is back in use.`
: `<code>${locEsc(b.path)}</code> is no longer offered on new work packages. `
+ 'Existing packages that reference it are unchanged.');
if(node) node.active = t.checked;
return;
}
if(t.classList.contains('loc-name')){
const node = _locNodes.find(n => n.id === t.dataset.id);
const next = (t.value || '').trim();
if(!node || next === node.name){ if(node) t.value = node.name; return; }
if(!next){ t.value = node.name; return; }
locPatch(t.dataset.id, {name: next},
b => `Renamed to “${locEsc(b.name)}”. Its code <code>${locEsc(b.path)}</code> is unchanged, `
+ 'so work packages already using it still point at the same value.');
}
});
}
});
// ── STEP GATES (A4 / S9) ────────────────────────────────────────────────────── // ── STEP GATES (A4 / S9) ──────────────────────────────────────────────────────
// validateStep() guarded steps 1, 5 and 6 with three hand-written conditions and // validateStep() guarded steps 1, 5 and 6 with three hand-written conditions and
// three hand-written messages. The rail needs the same answer for every step, not // three hand-written messages. The rail needs the same answer for every step, not
@@ -1237,7 +1525,9 @@ const STEP_GATES = {
}; };
const STEP_LABELS = {1:'Project', 2:'Team', 3:'Sign-offs', 4:'WP types', 5:'Governance', const STEP_LABELS = {1:'Project', 2:'Team', 3:'Sign-offs', 4:'WP types', 5:'Governance',
6:'Quality', 7:'Platforms', 8:'Sequence', 9:'Constraints', 10:'Sources'}; 6:'Quality', 7:'Platforms', 8:'Sequence', 9:'Constraints', 10:'Sources',
11:'Locations'};
const LAST_STEP = 11;
// Steps you have actually been on. A stepper's tick means "done", and a step you // Steps you have actually been on. A stepper's tick means "done", and a step you
// have never opened is not done however its defaults happen to read — step 6's QC // have never opened is not done however its defaults happen to read — step 6's QC
@@ -1307,7 +1597,7 @@ if(typeof WPUrl !== 'undefined'){
function nextStep(){ function nextStep(){
if(!validateStep(currentStep)) return; if(!validateStep(currentStep)) return;
if(currentStep < 10){ if(currentStep < LAST_STEP){
railMessage(''); railMessage('');
currentStep++; currentStep++;
_visitedSteps.add(currentStep); _visitedSteps.add(currentStep);
@@ -1375,6 +1665,8 @@ function renderStepRail(){
}); });
const pos = document.getElementById('step-rail-pos'); const pos = document.getElementById('step-rail-pos');
if(pos) pos.textContent = currentStep; if(pos) pos.textContent = currentStep;
const total = document.getElementById('step-rail-total');
if(total) total.textContent = LAST_STEP;
const here = document.getElementById('step-rail-here'); const here = document.getElementById('step-rail-here');
if(here) here.textContent = STEP_LABELS[currentStep] || ''; if(here) here.textContent = STEP_LABELS[currentStep] || '';
} }
@@ -1475,10 +1767,15 @@ function updateStepUI(){
renderStepRail(); renderStepRail();
// The location list lives on the server, so it is fetched when the step is
// actually opened rather than on every page load. locLoad() is idempotent and
// no-ops once it has the list.
if(currentStep === 11 && typeof locLoad === 'function') locLoad();
// Update buttons // Update buttons
document.getElementById('sop-prev-btn').disabled = currentStep === 1; document.getElementById('sop-prev-btn').disabled = currentStep === 1;
document.getElementById('sop-next-btn').style.display = currentStep < 10 ? 'block' : 'none'; document.getElementById('sop-next-btn').style.display = currentStep < LAST_STEP ? 'block' : 'none';
document.getElementById('sop-complete-btn').style.display = currentStep === 10 ? 'block' : 'none'; document.getElementById('sop-complete-btn').style.display = currentStep === LAST_STEP ? 'block' : 'none';
// Collect form data // Collect form data
collectStepData(); collectStepData();
@@ -1558,7 +1855,7 @@ function completeSOP(){
'Ask a project admin to make the change — the SOP is the baseline every work package inherits.'); 'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
return; return;
} }
if(!validateStep(10)) return; if(!validateStep(LAST_STEP)) return;
collectStepData(); collectStepData();
sop = { sop = {
@@ -1800,7 +2097,7 @@ function showAnalytics(){
const s = analyticsSummary(); const s = analyticsSummary();
const fmtMin = ms => (ms/60000).toFixed(1)+' min'; const fmtMin = ms => (ms/60000).toFixed(1)+' min';
let txt = `USAGE LOGS\n\nSessions: ${s.sessions} Events: ${s.total}\nRange: ${s.first?new Date(s.first).toLocaleString():'—'}${s.last?new Date(s.last).toLocaleString():'—'}\n\nStep views:\n`; let txt = `USAGE LOGS\n\nSessions: ${s.sessions} Events: ${s.total}\nRange: ${s.first?new Date(s.first).toLocaleString():'—'}${s.last?new Date(s.last).toLocaleString():'—'}\n\nStep views:\n`;
for(let i=1;i<=10;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`; for(let i=1;i<=LAST_STEP;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`;
txt += `\nActions:\n`; txt += `\nActions:\n`;
Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`); Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`);
txt += ` field edits: ${s.byEvent['field_edit']||0}\n`; txt += ` field edits: ${s.byEvent['field_edit']||0}\n`;

View File

@@ -625,6 +625,83 @@ body.embed-full { overflow: hidden; }
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; } .nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ══ LOCATION LIST — CR-005 (step 11) ═════════════════════════════════════════
Building / floor / sector, configured per project. The code beside each name
is the thing CR-018 rolls cost up by, and it is shown rather than hidden so it
is obvious that renaming a value does not move it. */
.loc-actions { display: flex; gap: var(--wp-s2); flex-wrap: wrap; margin-top: var(--wp-s3); }
.loc-report { margin-top: var(--wp-s3); font-size: 13px; }
.loc-report:empty { display: none; }
.loc-report-line { margin: 0 0 var(--wp-s2); }
.loc-report-group { margin-top: var(--wp-s3); }
.loc-report-title { font-size: 12px; font-weight: 600; color: var(--text-light); }
.loc-report-list { margin: var(--wp-s1) 0 0; padding-left: var(--wp-s5); }
.loc-report-list li { margin-bottom: var(--wp-s1); color: var(--text-light); }
.loc-line { font-weight: 600; color: var(--text); }
/* A report that lost rows says so in its own right, not only by wording. */
.loc-report.is-problem { border-left: 3px solid var(--warning); padding-left: var(--wp-s3);
background: var(--warning-bg); padding-top: var(--wp-s2); padding-bottom: var(--wp-s2); }
#loc-list { display: flex; flex-direction: column; margin-top: var(--wp-s3); }
.loc-row {
display: flex;
align-items: center;
gap: var(--wp-s3);
padding: var(--wp-s2);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
/* Indentation is the hierarchy, and it is not the only cue — .loc-level names
the level in words beside it (C1: never colour or position alone). */
.loc-depth-1 { padding-left: var(--wp-s5); }
.loc-depth-2 { padding-left: var(--wp-s6); }
.loc-level {
flex: none; min-width: 68px;
font-size: 11px; font-weight: 600; letter-spacing: .04em;
color: var(--text-light);
}
.loc-name {
flex: 1 1 180px; min-width: 0;
padding: var(--wp-s1) var(--wp-s2);
border: 1px solid var(--border-strong);
border-radius: 0;
background: var(--cds-field);
color: var(--text);
font: inherit;
font-size: 13px;
}
.loc-code {
flex: none;
font-family: var(--wp-font-mono);
font-size: 12px;
color: var(--text-light);
}
.loc-toggle { display: flex; align-items: center; gap: var(--wp-s1); font-size: 12px; color: var(--text-light); }
/* A deactivated value is still listed — that is how it is brought back — but it
reads as out of use, by strikethrough as well as by the unticked box. */
.loc-row.is-off .loc-name { text-decoration: line-through; color: var(--text-light); }
.loc-row.is-off .loc-code { text-decoration: line-through; }
.loc-addrow {
display: flex; align-items: center; gap: var(--wp-s2); flex-wrap: wrap;
margin-top: var(--wp-s4);
}
.loc-addlabel { font-size: 12px; font-weight: 600; color: var(--text-light); }
.loc-addrow select, .loc-addrow input {
padding: var(--wp-s2);
border: 1px solid var(--border-strong);
border-radius: 0;
background: var(--cds-field);
color: var(--text);
font: inherit;
font-size: 13px;
}
.loc-addrow input { flex: 1 1 200px; min-width: 0; }
/* .field-error is declared once, in theme-light.css — the launcher's create form
and T5.8's step validation use the same component. */
/* NAVIGATION */ /* NAVIGATION */
.step-navigation { .step-navigation {
grid-column: 2; grid-column: 2;

View File

@@ -84,7 +84,7 @@
where-you-are and opens on demand. --> where-you-are and opens on demand. -->
<button type="button" class="step-rail-toggle" id="step-rail-toggle" <button type="button" class="step-rail-toggle" id="step-rail-toggle"
aria-expanded="false" aria-controls="step-rail-list"> aria-expanded="false" aria-controls="step-rail-list">
<span class="step-rail-toggle-text">Step <span id="step-rail-pos">1</span> of 10 · <span id="step-rail-here">Project</span></span> <span class="step-rail-toggle-text">Step <span id="step-rail-pos">1</span> of <span id="step-rail-total">11</span> · <span id="step-rail-here">Project</span></span>
<span class="step-rail-toggle-chev" aria-hidden="true"></span> <span class="step-rail-toggle-chev" aria-hidden="true"></span>
</button> </button>
<ol class="step-rail-list" id="step-rail-list"> <ol class="step-rail-list" id="step-rail-list">
@@ -98,6 +98,13 @@
<li class="step-rail-item"><button type="button" class="step-btn" data-step="8"><span class="step-btn-marker" aria-hidden="true">8</span><span class="step-btn-body"><span class="step-btn-label">Sequence</span><span class="step-btn-state"></span></span></button></li> <li class="step-rail-item"><button type="button" class="step-btn" data-step="8"><span class="step-btn-marker" aria-hidden="true">8</span><span class="step-btn-body"><span class="step-btn-label">Sequence</span><span class="step-btn-state"></span></span></button></li>
<li class="step-rail-item"><button type="button" class="step-btn" data-step="9"><span class="step-btn-marker" aria-hidden="true">9</span><span class="step-btn-body"><span class="step-btn-label">Constraints</span><span class="step-btn-state"></span></span></button></li> <li class="step-rail-item"><button type="button" class="step-btn" data-step="9"><span class="step-btn-marker" aria-hidden="true">9</span><span class="step-btn-body"><span class="step-btn-label">Constraints</span><span class="step-btn-state"></span></span></button></li>
<li class="step-rail-item"><button type="button" class="step-btn" data-step="10"><span class="step-btn-marker" aria-hidden="true">10</span><span class="step-btn-body"><span class="step-btn-label">Sources</span><span class="step-btn-state"></span></span></button></li> <li class="step-rail-item"><button type="button" class="step-btn" data-step="10"><span class="step-btn-marker" aria-hidden="true">10</span><span class="step-btn-body"><span class="step-btn-label">Sources</span><span class="step-btn-state"></span></span></button></li>
<!-- CR-005. Appended rather than inserted next to Project, which is where
it belongs by subject: renumbering steps 2-10 would touch every
sop-step-N id, every collectStepData case, every gate key and the
analytics history, for an ordering change. Numbers are cheap to
reorder once nothing depends on them; a renumbering buried in a
feature diff is not. -->
<li class="step-rail-item"><button type="button" class="step-btn" data-step="11"><span class="step-btn-marker" aria-hidden="true">11</span><span class="step-btn-body"><span class="step-btn-label">Locations</span><span class="step-btn-state"></span></span></button></li>
</ol> </ol>
<!-- Why a step you asked for did not open. role="alert" because a refused <!-- Why a step you asked for did not open. role="alert" because a refused
navigation is an error and waiting for a pause to say so is too late navigation is an error and waiting for a pause to say so is too late
@@ -385,6 +392,61 @@
<button class="add-btn" onclick="addSource()">+ Add source</button> <button class="add-btn" onclick="addSource()">+ Add source</button>
</div> </div>
<!-- STEP 11: LOCATIONS (CR-005)
Server-backed, not localStorage: CR-018 rolls cost up by these
values, and a taxonomy each browser keeps its own copy of cannot
be rolled up by anything. See CLAUDE.md, "Frontend and backend
boundary". -->
<div class="step" id="sop-step-11" style="display: none;">
<h2>11. Location List</h2>
<div class="notice">Building, floor and sector for this project. The shape of a
location differs per job — on some, floors within one building behave like
separate buildings and are the unit of both execution and cost tracking — so it
is configured here rather than assumed. Work packages pick from this list, and
cost rolls up by it.</div>
<div id="loc-noproject" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div id="loc-tool">
<div class="field-grid col1">
<div class="field">
<label for="loc-paste">Paste rows, or upload a CSV</label>
<textarea id="loc-paste" rows="6" aria-describedby="loc-paste-hint"
placeholder="One row per sector, e.g.&#10;Building, Floor, Sector"></textarea>
<small id="loc-paste-hint">One row per value, deepest level last:
<strong>building, floor, sector</strong>. Two columns describe a floor,
one describes a building. Comma, semicolon or tab separated — a paste
straight out of a spreadsheet works. A header row is ignored.</small>
</div>
</div>
<div class="loc-actions">
<input type="file" id="loc-file" accept=".csv,.txt,text/csv,text/plain" hidden>
<button type="button" class="add-btn" id="loc-file-btn">Choose a CSV file…</button>
<button type="button" class="add-btn" id="loc-check-btn">Check without importing</button>
<button type="button" class="add-btn" id="loc-import-btn">Import</button>
<button type="button" class="add-btn" id="loc-sample-btn">Load sample values</button>
</div>
<!-- Rejected rows and duplicates land here, with line numbers. An
import that says "42 rows" over a file with 50 in it has lost
eight and told nobody, which is what this exists to prevent. -->
<div class="loc-report" id="loc-report" role="status"></div>
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
<div class="sub-heading">Current list</div>
<p class="field-hint" id="loc-count"></p>
<div id="loc-list"></div>
<div class="loc-addrow">
<label class="loc-addlabel" for="loc-add-parent">Add under</label>
<select id="loc-add-parent"></select>
<label class="loc-addlabel" for="loc-add-name">Name</label>
<input type="text" id="loc-add-name" placeholder="e.g. a new sector">
<button type="button" class="add-btn" id="loc-add-btn">+ Add</button>
</div>
<div class="field-error" id="loc-add-err" role="alert"></div>
</div>
</div>
</div>
</div> </div>
<!-- SOP NAVIGATION --> <!-- SOP NAVIGATION -->

View File

@@ -0,0 +1,65 @@
"""per-project Building / Floor / Sector taxonomy (CR-005)
The location taxonomy differs per project — on Micron, floors within B100 behave
like separate buildings — so it is configured once per SOP instead of hard-coded.
CR-018 rolls cost up by these values, which is why the table stores CODES
(`code`, `path`) beside the display `name`: a rollup keyed on a label breaks the
day somebody fixes a typo in it.
`active` rather than a delete. Deactivating hides a value from new work packages
while every package already referencing it still resolves its label, which is the
same rule CR-002 and CR-016 apply to fields.
Additive only: a new table, no change to any existing one, so nothing to backfill
and nothing to migrate.
Revision ID: e2a4c7d91b30
Revises: a7c31f9e5b02
Create Date: 2026-08-16 09:41:02.118307
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e2a4c7d91b30'
down_revision = 'a7c31f9e5b02'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'location_nodes',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=False),
sa.Column('parent_id', sa.String(length=40), nullable=True),
sa.Column('level', sa.String(length=20), nullable=False, server_default='building'),
sa.Column('code', sa.String(length=60), nullable=False, server_default=''),
sa.Column('path', sa.String(length=200), nullable=False, server_default=''),
sa.Column('name', sa.String(length=200), nullable=False, server_default=''),
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_by', sa.String(length=200), nullable=False, server_default=''),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
# One row per (project, path). This is what makes a re-import report a
# duplicate instead of quietly creating a second B100/L2/1P.
sa.UniqueConstraint('project_id', 'path', name='uq_location_path'),
)
op.create_index(op.f('ix_location_nodes_project_id'), 'location_nodes',
['project_id'], unique=False)
op.create_index(op.f('ix_location_nodes_parent_id'), 'location_nodes',
['parent_id'], unique=False)
op.create_index(op.f('ix_location_nodes_path'), 'location_nodes', ['path'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_location_nodes_path'), table_name='location_nodes')
op.drop_index(op.f('ix_location_nodes_parent_id'), table_name='location_nodes')
op.drop_index(op.f('ix_location_nodes_project_id'), table_name='location_nodes')
op.drop_table('location_nodes')

View File

@@ -1868,6 +1868,350 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
} }
# ── Location taxonomy — CR-005 ────────────────────────────────────────────────
# Building / Floor / Sector, configured once per project rather than hard-coded.
# See models.LocationNode for why this stores codes as well as names, and why
# nothing here deletes.
LOCATION_LEVELS = ("building", "floor", "sector")
_SLUG_STRIP = re.compile(r"[^A-Z0-9]+")
def location_slug(name: str) -> str:
"""A stable code from a display name: upper-cased, non-alphanumerics collapsed
to a single dash, trimmed. `Level 2` -> `LEVEL-2`, `1P (chase)` -> `1P-CHASE`.
Derived ONCE, at import, and never recomputed — see LocationNode. Returns ''
when there is nothing to make a code from, which the caller turns into a
rejected row with a reason rather than a silently-skipped one."""
return _SLUG_STRIP.sub("-", (name or "").strip().upper()).strip("-")[:60]
def parse_location_rows(text: str) -> tuple[list[tuple[int, list[str]]], list[dict]]:
"""Split pasted or uploaded text into (rows, rejected).
Rows come back paired with their SOURCE line number, not their index among the
accepted ones. "Duplicate on row 12" has to mean row 12 of the file somebody is
looking at, or the report sends them to the wrong line.
Accepts comma, tab or semicolon separators — a paste out of Excel is
tab-separated and a saved CSV is not, and asking which one somebody has is a
question the machine can answer. A header line naming the levels is skipped.
Every rejection carries the line number and a reason. A silent skip is the
failure mode this endpoint exists to avoid: an import that says "42 rows" over
a file with 50 in it has lost eight and told nobody."""
rows: list[tuple[int, list[str]]] = []
rejected: list[dict] = []
for i, raw in enumerate((text or "").splitlines(), start=1):
line = raw.strip()
if not line:
continue
if "\t" in raw:
parts = [c.strip() for c in raw.split("\t")]
elif ";" in line and "," not in line:
parts = [c.strip() for c in line.split(";")]
else:
parts = [c.strip() for c in line.split(",")]
parts = [p.strip().strip('"').strip() for p in parts]
while parts and not parts[-1]:
parts.pop()
if not parts:
continue
low = [p.lower() for p in parts]
if i == 1 and low[:1] in (["building"], ["bldg"]):
continue # header row
if len(parts) > len(LOCATION_LEVELS):
rejected.append({"line": i, "text": line,
"reason": "more than 3 columns — expected building, floor, sector"})
continue
if not parts[0]:
rejected.append({"line": i, "text": line,
"reason": "no building — a floor or sector needs one above it"})
continue
# A gap in the middle ("B100,,1P") would attach a sector to nothing.
gap = next((n for n, p in enumerate(parts) if not p), None)
if gap is not None and any(parts[gap + 1:]):
rejected.append({"line": i, "text": line,
"reason": "a %s is named with no %s above it"
% (LOCATION_LEVELS[len(parts) - 1], LOCATION_LEVELS[gap])})
continue
parts = [p for p in parts if p]
if any(not location_slug(p) for p in parts):
rejected.append({"line": i, "text": line,
"reason": "no letters or digits to make a code from"})
continue
rows.append((i, parts))
return rows, rejected
def _location_tree(db: Session, project_id: str, include_inactive: bool):
stmt = select(models.LocationNode).where(models.LocationNode.project_id == project_id)
if not include_inactive:
stmt = stmt.where(models.LocationNode.active.is_(True))
nodes = db.scalars(stmt.order_by(models.LocationNode.path)).all()
return nodes
@app.get("/api/projects/{project_id}/locations")
def list_locations(project_id: str,
include_inactive: bool = Query(False),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db)):
"""The project's taxonomy, flat, ordered by path so a caller can rebuild the
tree without a second query.
`include_inactive` defaults to FALSE, which is what makes a deactivated value
disappear from new work packages. It is available as true because a package
that already references a deactivated value still has to render its label —
deactivating hides a choice, it does not rewrite history."""
if not db.get(models.Project, project_id):
raise HTTPException(status_code=404, detail="Project not found")
require_project_access(db, user, project_id)
nodes = _location_tree(db, project_id, include_inactive)
return {
"project_id": project_id,
"levels": list(LOCATION_LEVELS),
"nodes": [n.to_dict() for n in nodes],
"counts": {lvl: sum(1 for n in nodes if n.level == lvl) for lvl in LOCATION_LEVELS},
"generated_at": models.utcnow().isoformat(),
}
class LocationImportIn(BaseModel):
text: str = ""
dry_run: bool = False
@app.post("/api/projects/{project_id}/locations/import")
def import_locations(project_id: str, body: LocationImportIn,
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db)):
"""Bulk import from CSV or a paste. Same code path for both: a file is read in
the browser and posted as text, because two parsers would be two sets of rules
about what a blank column means.
Reports rather than merges. A row naming a building/floor/sector combination
that already exists — in this file or in the project — comes back in
`duplicates` with its line number. Reusing a PARENT is not a duplicate:
`B100,L2,1P` and `B100,L2,2P` share a building and a floor by design, and it
is only the full path repeating that is a duplicate.
`dry_run` parses and reports without writing, which is what lets the wizard
show what an import will do before it does it."""
if not db.get(models.Project, project_id):
raise HTTPException(status_code=404, detail="Project not found")
# Any project member, matching how the SOP baseline itself is authored — the
# Project Admin gate is on CHANGING a completed SOP, not on writing one.
require_project_access(db, user, project_id)
require_project_writable(db, user, project_id, "The location list cannot be changed")
rows, rejected = parse_location_rows(body.text)
existing = {n.path: n for n in db.scalars(
select(models.LocationNode).where(models.LocationNode.project_id == project_id)
).all()}
before = set(existing)
created: list[dict] = []
duplicates: list[dict] = []
reactivated: list[dict] = []
seen_in_file: dict[str, int] = {}
next_sort = max((n.sort for n in existing.values()), default=0)
for line_no, parts in rows:
segs = [location_slug(p) for p in parts]
full = "/".join(segs)
if full in seen_in_file:
duplicates.append({"line": line_no, "path": full, "names": parts,
"reason": "already on line %d of this import" % seen_in_file[full]})
continue
seen_in_file[full] = line_no
if full in before:
node = existing[full]
if not node.active:
# Re-importing a value somebody deactivated is a request to bring it
# back, not a duplicate — and it must reuse the SAME row, or every
# work package pointing at the old path is orphaned.
if not body.dry_run:
node.active = True
reactivated.append({"path": full, "names": parts})
else:
duplicates.append({"line": line_no, "path": full, "names": parts,
"reason": "already in this project"})
continue
# Create any missing ancestors, then the leaf. Sharing a parent is the
# normal case, not a collision.
parent_id = None
for depth, seg in enumerate(segs):
sub = "/".join(segs[:depth + 1])
node = existing.get(sub)
if node is None:
next_sort += 1
node = models.LocationNode(
id=gen_id("loc"), project_id=project_id, parent_id=parent_id,
level=LOCATION_LEVELS[depth], code=seg, path=sub,
name=parts[depth], active=True, sort=next_sort,
created_by=user.username,
)
existing[sub] = node
if not body.dry_run:
db.add(node)
if sub not in before:
created.append({"path": sub, "level": LOCATION_LEVELS[depth],
"code": seg, "name": parts[depth]})
parent_id = node.id
result = {
"project_id": project_id, "dry_run": bool(body.dry_run),
"read": len(rows) + len(rejected),
"created": created, "duplicates": duplicates,
"reactivated": reactivated, "rejected": rejected,
}
if body.dry_run:
db.rollback()
return result
log_event(db, user, "locations_imported", "project", project_id, project_id,
summary="%d location value(s) added" % len(created),
detail={"created": len(created), "duplicates": len(duplicates),
"rejected": len(rejected), "reactivated": len(reactivated)})
db.commit()
return result
class LocationIn(BaseModel):
level: str = "building"
parent_id: Optional[str] = None
name: str = ""
@app.post("/api/projects/{project_id}/locations")
def add_location(project_id: str, body: LocationIn,
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db)):
"""Add one value by hand. Same rules as the import — an import you cannot
correct afterwards is an import nobody trusts enough to run."""
if not db.get(models.Project, project_id):
raise HTTPException(status_code=404, detail="Project not found")
# Any project member, matching how the SOP baseline itself is authored — the
# Project Admin gate is on CHANGING a completed SOP, not on writing one.
require_project_access(db, user, project_id)
require_project_writable(db, user, project_id, "The location list cannot be changed")
name = (body.name or "").strip()
code = location_slug(name)
if not code:
raise HTTPException(status_code=400,
detail="That name has no letters or digits to make a code from")
level = (body.level or "").strip().lower()
if level not in LOCATION_LEVELS:
raise HTTPException(status_code=400,
detail="Level must be one of: " + ", ".join(LOCATION_LEVELS))
depth = LOCATION_LEVELS.index(level)
parent = None
if body.parent_id:
parent = db.get(models.LocationNode, body.parent_id)
if not parent or parent.project_id != project_id:
raise HTTPException(status_code=400, detail="Parent is not on this project")
if depth == 0 and parent is not None:
raise HTTPException(status_code=400, detail="A building has nothing above it")
if depth > 0 and parent is None:
raise HTTPException(status_code=400,
detail="A %s needs a %s above it" % (level, LOCATION_LEVELS[depth - 1]))
if parent is not None and LOCATION_LEVELS.index(parent.level) != depth - 1:
raise HTTPException(status_code=400,
detail="A %s cannot sit under a %s" % (level, parent.level))
path = (parent.path + "/" + code) if parent else code
clash = db.scalars(select(models.LocationNode).where(
(models.LocationNode.project_id == project_id) & (models.LocationNode.path == path)
)).first()
if clash:
if not clash.active:
clash.active = True
log_event(db, user, "location_reactivated", "project", project_id, project_id,
summary=path)
db.commit()
return clash.to_dict()
raise HTTPException(status_code=409, detail="%s” is already on this project" % name)
top = db.scalar(select(func.max(models.LocationNode.sort)).where(
models.LocationNode.project_id == project_id)) or 0
node = models.LocationNode(
id=gen_id("loc"), project_id=project_id, parent_id=(parent.id if parent else None),
level=level, code=code, path=path, name=name, active=True, sort=top + 1,
created_by=user.username,
)
db.add(node)
log_event(db, user, "location_added", "project", project_id, project_id, summary=path)
db.commit()
return node.to_dict()
class LocationPatch(BaseModel):
name: Optional[str] = None
active: Optional[bool] = None
@app.patch("/api/projects/{project_id}/locations/{node_id}")
def update_location(project_id: str, node_id: str, body: LocationPatch,
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db)):
"""Rename or deactivate. There is no DELETE, and that is the design:
rename changes `name` only. `code` and `path` are untouched, so every
work package pointing at this value keeps pointing at it. That
is the whole reason the two are separate columns.
deactivate hides the value from new work packages. Cascades DOWN — a floor
nobody can pick makes its sectors unpickable too, and leaving
them offered would be offering a path to nowhere. Reactivating
a child reactivates its ancestors for the same reason.
"""
if not db.get(models.Project, project_id):
raise HTTPException(status_code=404, detail="Project not found")
# Any project member, matching how the SOP baseline itself is authored — the
# Project Admin gate is on CHANGING a completed SOP, not on writing one.
require_project_access(db, user, project_id)
require_project_writable(db, user, project_id, "The location list cannot be changed")
node = db.get(models.LocationNode, node_id)
if not node or node.project_id != project_id:
raise HTTPException(status_code=404, detail="Location not found on this project")
changed = {}
if body.name is not None:
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="A name cannot be empty")
if name != node.name:
changed["name"] = {"from": node.name, "to": name}
node.name = name
if body.active is not None and bool(body.active) != bool(node.active):
changed["active"] = {"from": bool(node.active), "to": bool(body.active)}
node.active = bool(body.active)
if not node.active:
for child in db.scalars(select(models.LocationNode).where(
(models.LocationNode.project_id == project_id)
& (models.LocationNode.path.like(node.path + "/%"))
)).all():
child.active = False
else:
# Walk up by path rather than by parent_id: one query, and it cannot
# loop on a malformed chain.
segs = node.path.split("/")
ancestors = ["/".join(segs[:n]) for n in range(1, len(segs))]
if ancestors:
for anc in db.scalars(select(models.LocationNode).where(
(models.LocationNode.project_id == project_id)
& (models.LocationNode.path.in_(ancestors))
)).all():
anc.active = True
if changed:
log_event(db, user, "location_updated", "project", project_id, project_id,
summary=node.path, detail=changed)
db.commit()
return node.to_dict()
@app.get("/api/projects/{project_id}/summary") @app.get("/api/projects/{project_id}/summary")
def project_summary(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)): def project_summary(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""What the launcher needs to describe a project without asking the browser """What the launcher needs to describe a project without asking the browser

View File

@@ -224,6 +224,68 @@ class ProjectMember(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class LocationNode(Base):
"""One value in a project's Building / Floor / Sector taxonomy — CR-005.
The taxonomy differs per project. On Micron, floors within B100 behave like
separate buildings, so floor and sector are the unit of both execution and
cost tracking; on another job "building" may be the only level that means
anything. So it is configured once per SOP rather than hard-coded, and no
real-world floor name appears anywhere in this repository.
CODES, NOT DISPLAY STRINGS. `CR-018` rolls cost up by these, and a rollup
keyed on a label breaks the day somebody fixes a typo in it. Two columns
carry that:
code this node's own slug among its siblings, derived once from the name
it was imported with and then NEVER recomputed — renaming a node is
a display change, which is exactly what makes rename safe for the
work packages already pointing at it.
path the full slug path from the root, '/'-joined and unique per project
(`B100/L2/1P`). This is the grouping key and the value a work
package stores.
DEACTIVATE, NEVER DELETE. `active=False` hides a value from new work
packages; every existing package referencing it still resolves its label,
because the row is still there. Same rule as `CR-002`/`CR-016`: removal is
expressed as a toggle, and the data is retained.
"""
__tablename__ = "location_nodes"
__table_args__ = (
UniqueConstraint("project_id", "path", name="uq_location_path"),
)
LEVELS = ("building", "floor", "sector")
id: Mapped[str] = mapped_column(String(40), primary_key=True)
project_id: Mapped[str] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
# Self-reference by id. No ForeignKey to its own table for the same reason the
# rest of this file declares none — see the module docstring — and because a
# self-referential FK plus SQLite's deferred-constraint behaviour makes a bulk
# import fiddly for no gain. Orphans are prevented in the API, which is the
# only writer.
parent_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
level: Mapped[str] = mapped_column(String(20), default="building") # building | floor | sector
code: Mapped[str] = mapped_column(String(60), default="") # own slug
path: Mapped[str] = mapped_column(String(200), default="", index=True) # full slug path
name: Mapped[str] = mapped_column(String(200), default="") # display label
active: Mapped[bool] = mapped_column(Boolean, default=True)
sort: Mapped[int] = mapped_column(Integer, default=0)
created_by: Mapped[str] = mapped_column(String(200), default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
def to_dict(self) -> dict:
return {
"id": self.id, "project_id": self.project_id, "parent_id": self.parent_id,
"level": self.level, "code": self.code, "path": self.path, "name": self.name,
"active": bool(self.active), "sort": self.sort,
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
}
class Comment(Base): class Comment(Base):
__tablename__ = "comments" __tablename__ = "comments"

437
tests/locations_check.py Normal file
View File

@@ -0,0 +1,437 @@
#!/usr/bin/env python3
"""Per-project Building / Floor / Sector taxonomy — CR-005 (T5.4).
CLAUDE.md names this as one of the change requests that gets "silently half-built
if you treat it as frontend-only": it needs structured location storage and
aggregate endpoints, not localStorage. So the API is tested directly as well as
through the wizard, and one check greps the wizard's own code for a localStorage
write behind the list.
1. CSV upload and paste both work, and report rejected rows WITH reasons
2. duplicates are detected and reported, not silently merged
3. values are editable after import — rename and add
4. deactivating hides a value from new work packages; a package already
referencing it still resolves
5. values are stored as codes suitable for grouping, and renaming does not
move the code
6. no guessed real-world floor name exists anywhere in the code
Check 5 is the one with teeth. `CR-018` rolls cost up by these values, so the
grouping key has to survive a rename — the probe renames a node and demands its
path is byte-identical afterwards.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STUB = """
window.__dialogs = [];
window.alert = function (m) { window.__dialogs.push(['alert', String(m)]); };
window.confirm = function (m) { window.__dialogs.push(['confirm', String(m)]); return false; };
window.prompt = function (m) { window.__dialogs.push(['prompt', String(m)]); return null; };
true
"""
# Deliberately not a real building. IMPLEMENTATION.md section 8: the B100 floor
# and area list has not been supplied, and a probe that invented one would put a
# guessed Micron floor name in the repository just as surely as the app would.
GOOD = "\n".join([
"Building,Floor,Sector",
"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",
])
MESSY = "\n".join([
"Probe Building One,Probe Level 1,Probe Sector A", # duplicate of the above
",Probe Level 9,Probe Sector Z", # no building
"Probe Building Three,,Probe Sector Q", # sector with no floor
"Probe Building Three,Probe Level 1,Probe Sector A,too many",
"---,Probe Level 1", # no code can be made
"Probe Building Three,Probe Level 1,Probe Sector A", # genuinely new
"Probe Building Three,Probe Level 1,Probe Sector A", # duplicate within the file
])
def settle(seconds=1.4):
time.sleep(seconds)
def api(page, method, path, body=None):
"""Call the API from inside the page, so the session cookie rides along."""
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_step11(page, base, tok, project="projA"):
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.goto(base + "/work-package-suite.html?project=%s&step=11" % project)
settle(2.0)
page.eval(STUB)
def rows_in_list(page):
return json.loads(page.eval("""JSON.stringify(
[...document.querySelectorAll('#loc-list .loc-row')].map(r => ({
level: (r.querySelector('.loc-level')||{}).textContent,
name: (r.querySelector('.loc-name')||{}).value,
code: (r.querySelector('.loc-code')||{}).textContent,
active: !!(r.querySelector('.loc-active')||{}).checked,
off: r.classList.contains('is-off'),
})))"""))
def run(page, base, tok):
print("\n1. paste imports, and reports what it refused")
open_step11(page, base, tok)
chk("the wizard has an eleventh step and it is showing",
page.eval("getComputedStyle(document.getElementById('sop-step-11')).display") == "block",
page.eval("(document.getElementById('sop-step-11')||{}).id"))
chk("...named Locations in the rail",
page.eval("(document.querySelector('#step-rail-list .step-btn[data-step=\"11\"] "
".step-btn-label')||{}).textContent") == "Locations")
chk("...and it is the last step, so SOP complete moved with it",
page.eval("getComputedStyle(document.getElementById('sop-complete-btn')).display")
!= "none")
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
page.eval("document.getElementById('loc-paste').value = %s" % json.dumps(GOOD))
page.eval("document.getElementById('loc-check-btn').click()")
settle(1.2)
report = page.eval("(document.getElementById('loc-report')||{}).textContent||''")
# Four, not five: the header line is recognised and skipped. Asserted at the
# number rather than "greater than zero", because "read 4 of a 5-line file" is
# exactly the kind of quiet loss this report exists to make visible.
chk("checking without importing reads every data row, header skipped",
"4 rows read" in report, repr(report[:120]))
chk("...and says what it would create without creating it",
"9 values would be added" in report, repr(report[:120]))
chk("...and writes nothing", api(page, "GET", "/api/projects/projA/locations")
["body"]["nodes"] == [], api(page, "GET", "/api/projects/projA/locations")["body"])
page.eval("document.getElementById('loc-import-btn').click()")
settle(1.6)
listed = rows_in_list(page)
chk("importing creates the whole hierarchy, parents included", len(listed) == 9,
[r["code"] for r in listed])
chk("...two buildings", sum(1 for r in listed if r["level"] == "building") == 2,
[r["code"] for r in listed])
chk("...three floors", sum(1 for r in listed if r["level"] == "floor") == 3,
[r["code"] for r in listed])
chk("...four sectors", sum(1 for r in listed if r["level"] == "sector") == 4,
[r["code"] for r in listed])
chk("...and a shared parent is created once, not once per row",
[r["code"] for r in listed].count("PROBE-BUILDING-ONE") == 1,
[r["code"] for r in listed])
print("\n5. the values are codes, and a rename does not move one")
codes = [r["code"] for r in listed]
chk("every value carries a slug path, not a display string",
all(re.fullmatch(r"[A-Z0-9-]+(/[A-Z0-9-]+)*", c) for c in codes), codes)
chk("...with the hierarchy in the path, which is what CR-018 groups by",
"PROBE-BUILDING-ONE/PROBE-LEVEL-1/PROBE-SECTOR-A" in codes, codes)
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
target = [n for n in nodes if n["path"] == "PROBE-BUILDING-ONE/PROBE-LEVEL-2"][0]
renamed = api(page, "PATCH", "/api/projects/projA/locations/" + target["id"],
{"name": "Renamed Level"})
chk("renaming a value succeeds", renamed["status"] == 200, renamed)
chk("...changes the label", renamed["body"]["name"] == "Renamed Level", renamed["body"])
chk("...and leaves the code EXACTLY as it was",
renamed["body"]["path"] == target["path"] and renamed["body"]["code"] == target["code"],
[target["path"], renamed["body"]["path"]])
kids = [n for n in api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
if n["path"].startswith(target["path"] + "/")]
chk("...and its children's codes with it, so nothing beneath is orphaned",
kids and all(k["path"].startswith("PROBE-BUILDING-ONE/PROBE-LEVEL-2/") for k in kids),
[k["path"] for k in kids])
print("\n2. duplicates are reported, not merged")
page.eval("document.getElementById('loc-paste').value = %s" % json.dumps(MESSY))
page.eval("document.getElementById('loc-import-btn').click()")
settle(1.6)
report = page.eval("(document.getElementById('loc-report')||{}).textContent||''")
chk("the report names duplicates as duplicates", "Duplicates, not merged" in report,
repr(report[:200]))
chk("...one already in the project, one repeated inside the file",
"already in this project" in report and "already on line" in report, repr(report[:300]))
chk("...and rejected rows separately", "Rejected" in report, repr(report[:200]))
chk("...naming the reason for each", "no building" in report and "no floor above it" in report,
repr(report[:400]))
chk("...and the line number, so it can be found in the file",
re.search(r"row \d+", report) is not None, repr(report[:200]))
chk("the report interrupts when it lost rows (T4.5)",
page.eval("document.getElementById('loc-report').getAttribute('role')") == "alert")
after = rows_in_list(page)
chk("a duplicate row created nothing", len(after) == 9 + 3,
[r["code"] for r in after])
chk("...while the genuinely new rows in the same file did import",
"PROBE-BUILDING-THREE/PROBE-LEVEL-1/PROBE-SECTOR-A" in [r["code"] for r in after],
[r["code"] for r in after])
print("\n1b. a CSV file goes through the same parser as a paste")
page.eval("document.getElementById('loc-paste').value = ''")
page.eval("""(() => {
const dt = new DataTransfer();
dt.items.add(new File([%s], 'probe-locations.csv', {type: 'text/csv'}));
const el = document.getElementById('loc-file');
el.files = dt.files;
el.dispatchEvent(new Event('change', {bubbles: true}));
return true;
})()""" % json.dumps("Probe Building Four,Probe Level 1,Probe Sector A\n"))
for _ in range(20):
if page.eval("!!document.getElementById('loc-paste').value"):
break
time.sleep(0.2)
chk("choosing a file fills the same box a paste does",
"Probe Building Four" in page.eval("document.getElementById('loc-paste').value"),
page.eval("document.getElementById('loc-paste').value")[:80])
chk("...and says which file it read",
"probe-locations.csv" in page.eval(
"(document.getElementById('loc-report')||{}).textContent||''"))
page.eval("document.getElementById('loc-import-btn').click()")
settle(1.6)
chk("...and importing it works the same way",
"PROBE-BUILDING-FOUR/PROBE-LEVEL-1/PROBE-SECTOR-A"
in [r["code"] for r in rows_in_list(page)],
[r["code"] for r in rows_in_list(page)])
print("\n3. the list is editable after import")
page.eval("""(() => {
document.getElementById('loc-add-parent').value = '';
const n = document.getElementById('loc-add-name');
n.value = 'Probe Building Five';
document.getElementById('loc-add-btn').click();
return true;
})()""")
settle(1.4)
chk("a value can be added by hand",
"PROBE-BUILDING-FIVE" in [r["code"] for r in rows_in_list(page)],
[r["code"] for r in rows_in_list(page)])
page.eval("""(() => {
const n = document.getElementById('loc-add-name');
n.value = ' ';
document.getElementById('loc-add-btn').click();
return true;
})()""")
settle(0.6)
chk("...an empty one is refused at the field, not in a dialog",
bool((page.eval("(document.getElementById('loc-add-err')||{}).textContent||''")).strip()),
page.eval("(document.getElementById('loc-add-err')||{}).textContent||''"))
chk("...announced through a live region",
page.eval("document.getElementById('loc-add-err').getAttribute('role')") == "alert")
dup = api(page, "POST", "/api/projects/projA/locations",
{"level": "building", "name": "Probe Building Five"})
chk("...and adding one that already exists is refused with a reason",
dup["status"] == 409 and "already" in str(dup["body"].get("detail", "")).lower(), dup)
orphan = api(page, "POST", "/api/projects/projA/locations",
{"level": "sector", "name": "Probe Loose Sector"})
chk("...a sector with nothing above it is refused",
orphan["status"] == 400 and "floor" in str(orphan["body"].get("detail", "")).lower(),
orphan)
print("\n4. deactivating hides a value without breaking what references it")
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
floor = [n for n in nodes if n["path"] == "PROBE-BUILDING-ONE/PROBE-LEVEL-1"][0]
off = api(page, "PATCH", "/api/projects/projA/locations/" + floor["id"], {"active": False})
chk("a value can be deactivated", off["status"] == 200 and off["body"]["active"] is False, off)
live = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
live_paths = [n["path"] for n in live]
chk("...and disappears from the list new work packages choose from",
floor["path"] not in live_paths, live_paths)
chk("...taking its sectors with it, so nothing offers a path to nowhere",
not [p for p in live_paths if p.startswith(floor["path"] + "/")], live_paths)
chk("...while its building stays offered", "PROBE-BUILDING-ONE" in live_paths, live_paths)
all_nodes = api(page, "GET",
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
kept = [n for n in all_nodes if n["path"] == floor["path"]]
chk("...the row itself is RETAINED, not deleted", len(kept) == 1, [n["path"] for n in all_nodes])
chk("...so a work package already referencing it still resolves its label",
kept and kept[0]["name"] == floor["name"], kept)
chk("...and there is no DELETE route at all to lose it by",
api(page, "DELETE", "/api/projects/projA/locations/" + floor["id"])["status"] == 405,
api(page, "DELETE", "/api/projects/projA/locations/" + floor["id"]))
open_step11(page, base, tok)
shown = rows_in_list(page)
chk("the wizard still shows the deactivated value, so it can be brought back",
any(r["code"] == floor["path"] and r["off"] for r in shown),
[(r["code"], r["off"]) for r in shown])
page.eval("""(() => {
const row = [...document.querySelectorAll('#loc-list .loc-row')]
.find(r => (r.querySelector('.loc-code')||{}).textContent === %s);
const box = row.querySelector('.loc-active');
box.checked = true;
box.dispatchEvent(new Event('change', {bubbles: true}));
return true;
})()""" % json.dumps(floor["path"]))
settle(1.4)
back = [n["path"] for n in api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]]
chk("ticking it puts it back in use", floor["path"] in back, back)
chk("...on the SAME row, so nothing that referenced it was orphaned",
[n for n in api(page, "GET",
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
if n["path"] == floor["path"]][0]["id"] == floor["id"])
print("\n re-importing a deactivated value brings it back rather than duplicating it")
all_now = api(page, "GET",
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
live_now = [n["path"] for n in
api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]]
sec = [n for n in all_now if n["path"].endswith("/PROBE-SECTOR-B")][0]
# Deliberate asymmetry, pinned here so nobody "fixes" it into a surprise:
# deactivating a floor takes its sectors down with it, but ticking the floor
# back on does NOT resurrect them. A sector may have been switched off for its
# own reasons, and silently bringing it back would undo a decision nobody made
# twice. They stay listed, struck through, one tick away.
chk("reactivating a parent does not silently resurrect its children",
sec["path"] not in live_now, [sec["path"], live_now])
chk("...and they are still listed, so they can be brought back deliberately",
sec["active"] is False)
api(page, "PATCH", "/api/projects/projA/locations/" + sec["id"], {"active": False})
again = api(page, "POST", "/api/projects/projA/locations/import",
{"text": "Probe Building One,Probe Level 1,Probe Sector B"})
chk("the import reports it as reactivated, not created or duplicate",
len(again["body"]["reactivated"]) == 1 and not again["body"]["created"], again["body"])
same = [n for n in api(page, "GET",
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
if n["path"] == sec["path"]]
chk("...and reuses the same row", len(same) == 1 and same[0]["id"] == sec["id"], same)
print("\n6. no guessed real-world floor names, and no localStorage behind the list")
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||[])"))
src_js = open(os.path.join(ROOT, "html", "work-package-suite-app.js"), encoding="utf-8").read()
start = src_js.find("// ── STEP 11: LOCATION LIST")
end = src_js.find("// ── STEP GATES", start) if start >= 0 else -1
block = src_js[start:end] if start >= 0 and end > start else ""
chk("the locations code exists as its own bounded block", bool(block), [start, end])
# Comments stripped first: this block QUOTES CLAUDE.md's rule about localStorage,
# and a probe that failed on the sentence forbidding the thing rather than on the
# thing would be the least useful possible false positive.
code_only = "\n".join(ln for ln in block.splitlines()
if not ln.strip().startswith(("//", "*", "/*")))
chk("...and never touches localStorage", "localStorage" not in code_only,
[ln for ln in code_only.splitlines() if "localStorage" in ln][:3])
chk("...reaching the server for every read and write",
block.count("fetch(locApi") >= 4, block.count("fetch(locApi"))
# The B100 floor/area list has not been supplied (IMPLEMENTATION.md section 8),
# so any of these appearing as a location value would be a guess presented as
# data. Checked over the whole tree, not only the file this task touched.
GUESSES = ("B100", "1P", "2P", "Fab 7", "Level 2 chase")
hits = []
for folder in ("html", "server"):
for name in sorted(os.listdir(os.path.join(ROOT, folder))):
if not name.endswith((".js", ".html", ".py", ".css")):
continue
text = open(os.path.join(ROOT, folder, name), encoding="utf-8").read()
# Only where it would be a location VALUE — the review quotes "1P" in
# prose all over the place, and a comment is not a hardcoded floor.
for m in re.finditer(r"(building|floor|sector|location)\w*\s*[:=]\s*['\"]([^'\"]{1,40})",
text, re.I):
if any(g.lower() in m.group(2).lower() for g in GUESSES):
hits.append("%s/%s: %s" % (folder, name, m.group(0)[:60]))
chk("no guessed real-world floor or sector name is assigned anywhere", not hits, hits[:4])
sample = re.search(r"const LOCATION_SAMPLE = \[(.*?)\]", src_js, re.S)
chk("the seeded sample values are obviously fake", bool(sample)
and all("Sample" in ln for ln in re.findall(r"'([^']+)'", sample.group(1))),
sample.group(1)[:200] if sample else None)
print("\n both widths")
for w, label in ((390, "390px"), (1440, "1440px")):
page.viewport(w, 900, mobile=(w == 390))
open_step11(page, base, tok)
chk("%s: the location step renders" % label,
page.eval("document.querySelectorAll('#loc-list .loc-row').length") > 0)
chk("%s: the page does not scroll sideways" % label,
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
page.viewport(1400, 1000)
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-locations-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed(db_path)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
if server is None:
print("the test server would not start.")
return 2
print("\nLocation taxonomy — CR-005\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 — codes not labels, nothing merged, nothing deleted.", "32") + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -9,7 +9,7 @@ detached from the control it described.
Every one of T5.1's done-whens is checked here, because nothing that already Every one of T5.1's done-whens is checked here, because nothing that already
existed could check any of them: existed could check any of them:
1. all ten steps are <button> elements 1. every step is a <button> element (ten until T5.4 appended Locations)
2. the rail is operable by tab, arrow keys, Home/End, Enter and Space 2. the rail is operable by tab, arrow keys, Home/End, Enter and Space
3. the current step is exposed with aria-current 3. the current step is exposed with aria-current
4. complete / current / unavailable are distinguishable WITHOUT colour 4. complete / current / unavailable are distinguishable WITHOUT colour
@@ -51,6 +51,10 @@ HTML_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__
# Wave 0 recorded 12. T5.1 must take ten of them. # Wave 0 recorded 12. T5.1 must take ten of them.
BASELINE_DIV_ONCLICK = 12 BASELINE_DIV_ONCLICK = 12
# Ten until T5.4 (CR-005) appended Locations. Named rather than repeated, so the
# next step to be added moves one number instead of eight assertions.
STEP_COUNT = 11
# Swallow the wizard's remaining native dialogs and record that they fired. The # Swallow the wizard's remaining native dialogs and record that they fired. The
# wizard still has them until T5.8; this proves the RAIL never reaches one. # wizard still has them until T5.8; this proves the RAIL never reaches one.
STUB = """ STUB = """
@@ -196,10 +200,11 @@ def run(page, base, tok):
page.eval(STUB) page.eval(STUB)
# ── 1. every step is a real button ──────────────────────────────────────── # ── 1. every step is a real button ────────────────────────────────────────
print("\n1. all ten steps are <button> elements") print("\n1. every step is a <button> element")
open_wizard() open_wizard()
rows = rail(page) rows = rail(page)
chk("the rail renders ten steps", len(rows) == 10, "found %d" % len(rows)) chk("the rail renders %d steps" % STEP_COUNT, len(rows) == STEP_COUNT,
"found %d" % len(rows))
chk("...every one of them a <button>", rows and all(r["tag"] == "BUTTON" for r in rows), chk("...every one of them a <button>", rows and all(r["tag"] == "BUTTON" for r in rows),
[r["tag"] for r in rows]) [r["tag"] for r in rows])
chk('...with type="button", so none of them submits anything', chk('...with type="button", so none of them submits anything',
@@ -218,8 +223,8 @@ def run(page, base, tok):
chk("no #total-steps element", page.eval("!document.getElementById('total-steps')")) chk("no #total-steps element", page.eval("!document.getElementById('total-steps')"))
chk("no .step-counter anywhere on the page", chk("no .step-counter anywhere on the page",
page.eval("!document.querySelector('.step-counter')")) page.eval("!document.querySelector('.step-counter')"))
chk("...and no bare 'N / 10' left in the app bar", chk("...and no bare 'N / %d' left in the app bar" % STEP_COUNT,
not re.search(r"\b\d+\s*/\s*10\b", not re.search(r"\b\d+\s*/\s*%d\b" % STEP_COUNT,
page.eval("(document.querySelector('.header')||{}).textContent||''")), page.eval("(document.querySelector('.header')||{}).textContent||''")),
page.eval("(document.querySelector('.header')||{}).textContent||''")[:120]) page.eval("(document.querySelector('.header')||{}).textContent||''")[:120])
@@ -246,7 +251,8 @@ def run(page, base, tok):
# a fresh load and everything ahead of it is genuinely out of reach. # a fresh load and everything ahead of it is genuinely out of reach.
locked = [r for r in rows if r["ariaDisabled"] == "true"] locked = [r for r in rows if r["ariaDisabled"] == "true"]
chk("with step 1 incomplete, steps 2-10 are unavailable", chk("with step 1 incomplete, steps 2-10 are unavailable",
[r["step"] for r in locked] == list(range(2, 11)), [r["step"] for r in locked]) [r["step"] for r in locked] == list(range(2, STEP_COUNT + 1)),
[r["step"] for r in locked])
chk("...each saying 'Locked' in words, not only in colour", chk("...each saying 'Locked' in words, not only in colour",
locked and all(r["state"] == "Locked" for r in locked), locked and all(r["state"] == "Locked" for r in locked),
list({r["state"] for r in locked})) list({r["state"] for r in locked}))
@@ -300,7 +306,7 @@ def run(page, base, tok):
page.eval("(document.activeElement.dataset||{}).step")) page.eval("(document.activeElement.dataset||{}).step"))
press(page, "End") press(page, "End")
chk("End jumps to the last step", chk("End jumps to the last step",
page.eval("(document.activeElement.dataset||{}).step") == "10", page.eval("(document.activeElement.dataset||{}).step") == str(STEP_COUNT),
page.eval("(document.activeElement.dataset||{}).step")) page.eval("(document.activeElement.dataset||{}).step"))
press(page, "Home") press(page, "Home")
chk("Home jumps to the first", chk("Home jumps to the first",
@@ -332,7 +338,7 @@ def run(page, base, tok):
# have just filled in is worse than the strip it replaced. # have just filled in is worse than the strip it replaced.
rows = rail(page) rows = rail(page)
chk("emptying a required field re-locks the steps ahead, with no navigation", chk("emptying a required field re-locks the steps ahead, with no navigation",
[r["step"] for r in rows if r["ariaDisabled"] == "true"] == list(range(2, 11)), [r["step"] for r in rows if r["ariaDisabled"] == "true"] == list(range(2, STEP_COUNT + 1)),
[r["step"] for r in rows if r["ariaDisabled"] == "true"]) [r["step"] for r in rows if r["ariaDisabled"] == "true"])
chk("...and the guard refuses the same move", chk("...and the guard refuses the same move",
page.eval("(() => { const n = currentStep; goToStep(4); return currentStep === n; })()")) page.eval("(() => { const n = currentStep; goToStep(4); return currentStep === n; })()"))
@@ -426,9 +432,9 @@ def run(page, base, tok):
page.eval("(document.getElementById('step-rail-toggle')||{}).textContent||''")) page.eval("(document.getElementById('step-rail-toggle')||{}).textContent||''"))
page.click("#step-rail-toggle") page.click("#step-rail-toggle")
settle(page, 0.5) settle(page, 0.5)
chk("tapping it reveals all ten steps", chk("tapping it reveals every step",
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]" page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
".filter(b => b.getBoundingClientRect().height > 0).length") == 10, ".filter(b => b.getBoundingClientRect().height > 0).length") == STEP_COUNT,
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]" page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
".filter(b => b.getBoundingClientRect().height > 0).length")) ".filter(b => b.getBoundingClientRect().height > 0).length"))
chk("...and flips aria-expanded", page.eval( chk("...and flips aria-expanded", page.eval(