diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index a2d7149..cd5978c 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -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/index.html` | launcher | 703 | `theme-light.css`, `wp-chrome.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, *(inline 418–700)*, `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** | + +> **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 2–10 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/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 | @@ -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/launcher_check.py # B3 — can a brand-new account get started? 58 checks python tests/pipeline_check.py # B4 surface — server counts, shareable links 43 checks +python tests/locations_check.py # CR-005 — codes not labels, nothing deleted 58 checks ``` `pipeline_check.py` reads the dashboard's state out of the **iframe's DOM**, not its diff --git a/html/index.html b/html/index.html index 96ba243..eda881f 100644 --- a/html/index.html +++ b/html/index.html @@ -311,11 +311,8 @@ BL-014 as controls falling back to the UA focus ring; two of that entry's four sites no longer exist. */ - /* An inline error at the field it belongs to, rather than a native dialog that - names no field and highlights nothing (C1, and the pattern T5.8 uses in the - 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; } + /* .field-error — the inline error beside a field — is declared once, in + theme-light.css, because three surfaces in wave 5 grew one. */ /* PIPELINE STRIP — B4 surface (T5.3) Four counts, every one of them from /api/wps/metrics. There is deliberately diff --git a/html/theme-light.css b/html/theme-light.css index dc09ff5..98b72cb 100644 --- a/html/theme-light.css +++ b/html/theme-light.css @@ -508,6 +508,22 @@ input, textarea, select { .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; } +/* 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) ---------------------------------------------------------------------------- diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js index 94e3a11..161d1e8 100644 --- a/html/work-package-suite-app.js +++ b/html/work-package-suite-app.js @@ -309,7 +309,7 @@ window.addEventListener('DOMContentLoaded',()=>{ else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true}); else if(_deepLinkWp) switchTool('wp', {fromUrl:true}); 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){ 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 // 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; updateStepUI(); updateProjectDisplay(); @@ -472,7 +472,7 @@ function restoreSavedSOP(){ repopulateForm(); // 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. - 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); } @@ -1213,6 +1213,294 @@ function addSource(){ 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 = '

Nothing configured yet. Paste or upload a list above, ' + + 'or add values one at a time.

'; + 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 `
+ ${locEsc(n.level)} + + ${locEsc(n.path)} + +
`; + }).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 = [''].concat( + _locNodes.filter(n => n.active && n.level !== 'sector') + .map(n => ``) + ); + 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(`

${res.read} row${res.read===1?'':'s'} read. ` + + `${(res.created||[]).length} value${(res.created||[]).length===1?'':'s'} ${verb}` + + ((res.reactivated||[]).length ? `, ${res.reactivated.length} brought back into use` : '') + + `.

`); + const rowList = (title, rows, fmt) => { + if(!rows || !rows.length) return ''; + return `
${locEsc(title)} (${rows.length})
` + + '
'; + }; + bits.push(rowList('Rejected', res.rejected, + r => `
  • row ${r.line} ${locEsc(r.text)} — ${locEsc(r.reason)}
  • `)); + bits.push(rowList('Duplicates, not merged', res.duplicates, + r => `
  • row ${r.line} ${locEsc(r.path)} — ${locEsc(r.reason)}
  • `)); + if(!problem && !(res.created||[]).length && !(res.reactivated||[]).length){ + bits.push('

    Nothing to do — every row is already on this project.

    '); + } + 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 ${locEsc(res.body.path)}.`, 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 ${locEsc(f.name)}. 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 + ? `${locEsc(b.path)} is back in use.` + : `${locEsc(b.path)} 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 ${locEsc(b.path)} is unchanged, ` + + 'so work packages already using it still point at the same value.'); + } + }); + } +}); + // ── STEP GATES (A4 / S9) ────────────────────────────────────────────────────── // 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 @@ -1237,7 +1525,9 @@ const STEP_GATES = { }; 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 // 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(){ if(!validateStep(currentStep)) return; - if(currentStep < 10){ + if(currentStep < LAST_STEP){ railMessage(''); currentStep++; _visitedSteps.add(currentStep); @@ -1375,6 +1665,8 @@ function renderStepRail(){ }); const pos = document.getElementById('step-rail-pos'); 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'); if(here) here.textContent = STEP_LABELS[currentStep] || ''; } @@ -1475,10 +1767,15 @@ function updateStepUI(){ 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 document.getElementById('sop-prev-btn').disabled = currentStep === 1; - document.getElementById('sop-next-btn').style.display = currentStep < 10 ? 'block' : 'none'; - document.getElementById('sop-complete-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 === LAST_STEP ? 'block' : 'none'; // Collect form data collectStepData(); @@ -1558,7 +1855,7 @@ function completeSOP(){ 'Ask a project admin to make the change — the SOP is the baseline every work package inherits.'); return; } - if(!validateStep(10)) return; + if(!validateStep(LAST_STEP)) return; collectStepData(); sop = { @@ -1800,7 +2097,7 @@ function showAnalytics(){ const s = analyticsSummary(); 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`; - 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`; 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`; diff --git a/html/work-package-suite-styles.css b/html/work-package-suite-styles.css index 693b493..c62f28b 100644 --- a/html/work-package-suite-styles.css +++ b/html/work-package-suite-styles.css @@ -625,6 +625,83 @@ body.embed-full { overflow: hidden; } .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 */ .step-navigation { grid-column: 2; diff --git a/html/work-package-suite.html b/html/work-package-suite.html index aac2a95..7ca1335 100644 --- a/html/work-package-suite.html +++ b/html/work-package-suite.html @@ -84,7 +84,7 @@ where-you-are and opens on demand. -->
      @@ -98,6 +98,13 @@
    1. + +
    + + diff --git a/server/alembic/versions/e2a4c7d91b30_location_taxonomy.py b/server/alembic/versions/e2a4c7d91b30_location_taxonomy.py new file mode 100644 index 0000000..9a1b6c7 --- /dev/null +++ b/server/alembic/versions/e2a4c7d91b30_location_taxonomy.py @@ -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') diff --git a/server/app.py b/server/app.py index 945acbc..14b1385 100644 --- a/server/app.py +++ b/server/app.py @@ -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") 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 diff --git a/server/models.py b/server/models.py index 3050cfb..3f51acd 100644 --- a/server/models.py +++ b/server/models.py @@ -224,6 +224,68 @@ class ProjectMember(Base): 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): __tablename__ = "comments" diff --git a/tests/locations_check.py b/tests/locations_check.py new file mode 100644 index 0000000..6192436 --- /dev/null +++ b/tests/locations_check.py @@ -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()) diff --git a/tests/stepper_check.py b/tests/stepper_check.py index b50f18c..63578f3 100644 --- a/tests/stepper_check.py +++ b/tests/stepper_check.py @@ -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 existed could check any of them: - 1. all ten steps are