T8.6 - D6: the material list uploads the way the location list does

CR-013 accepted free text because the master workbook never arrived; the
Aug 18 call was the CR-005 call again - build the upload path now.

THE component, extracted: T5.4's paste-or-file machinery (file read in the
browser, ONE parser on the server; dry-run check; a report naming every
rejected row with its source line; an editable list that deactivates rather
than deletes) moved from the location-specific functions into
html/wp-list-import.js. The location list and the new material list are both
instances of it - the done-when's "against the same component, not beside it"
made literally true. The loc* names survive as thin delegates because row
handlers, step entry and the probes call them; locations_check re-pointed its
fetch-count assertion to where the fetches now live and still demands every
read and write reach the server.

The material list itself: description, unit, optional code - one new table
(Alembic a1b8c6d4e2f9, additive), GET/import/POST/PATCH routes on the CR-005
pattern, deactivate-never-delete, reactivation reuses the same row so nothing
referencing it orphans. The sample rows are obviously fake (SAMPLE-EMT-075).
NO inventory, price, stock or warehouse field anywhere - the probe walks the
model's columns by regex. The wizard hosts it on step 11 beside the location
list, optional by design: a project with no list still raises free-text
requests (T8.5 wires that).

Parser bug caught by the probe's first run: strip(',;') ate a LEADING comma,
so ',FT' - an empty description - was accepted as a material named FT.
rstrip only, now; the empty first column is rejected with its line number.

Verification (each probe run alone): NEW tests/materials_check.py 17/17.
Regression: locations_check 58/58 through the shared component.

Items: D6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 12:24:21 -07:00
parent b9d5f8ef92
commit 190144c539
9 changed files with 791 additions and 170 deletions

View File

@@ -292,6 +292,7 @@ Wave 8 adds these:
```bash ```bash
python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks
python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks
python tests/materials_check.py # D6 - material list, the CR-005 pattern 17 checks
``` ```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live

View File

@@ -1244,7 +1244,6 @@ const LOCATION_SAMPLE = [
].join('\n'); ].join('\n');
let _locNodes = []; let _locNodes = [];
let _locLoaded = false;
function locProjectId(){ function locProjectId(){
try { try {
@@ -1259,53 +1258,32 @@ function locApi(suffix){
} }
function locEsc(v){ return escAttr(v); } function locEsc(v){ return escAttr(v); }
// Every message on this step goes through here so the role is decided in one // D6 / T8.6: the paste-or-file import machinery moved to wp-list-import.js -
// place: a report that lost rows interrupts (T4.5), a clean one does not. // ONE component; the location list and the material list are both instances of
function locSay(html, isProblem){ // it. These names survive as thin delegates because the row handlers, the step
const el = document.getElementById('loc-report'); // entry and the probes all call them.
if(!el) return; const locList = WPListImport({
el.setAttribute('role', isProblem ? 'alert' : 'status'); prefix: 'loc', api: locApi, projectId: locProjectId, sample: LOCATION_SAMPLE,
el.innerHTML = html || ''; loadKey: 'nodes', esc: locEsc,
el.classList.toggle('is-problem', !!isProblem); render: function(){ _locNodes = locList.state.rows; locRender(); },
} rejectedRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
duplicateRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.path)}</code> — ${locEsc(r.reason)}</li>`,
function locSetAddError(msg){ addPayload: function(){
const el = document.getElementById('loc-add-err'); const name = ((document.getElementById('loc-add-name') || {}).value || '').trim();
if(el) el.textContent = msg || ''; if(!name) return {error: 'Enter a name for the value you are adding.'};
const input = document.getElementById('loc-add-name'); const parentId = (document.getElementById('loc-add-parent') || {}).value || '';
if(input){ const parent = _locNodes.find(n => n.id === parentId);
if(msg) input.setAttribute('aria-invalid', 'true'); const level = !parent ? 'building' : (parent.level === 'building' ? 'floor' : 'sector');
else input.removeAttribute('aria-invalid'); return {payload: {level: level, parent_id: parentId || null, name: name}};
} },
} addedMessage: body => `Added <code>${locEsc(body.path)}</code>.`,
noProjectMessage: 'Open this wizard from a project to configure its location list. '
function locLoad(force){ + 'The list is stored against the project on the server, not in this browser.',
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 locSay(html, isProblem){ locList.say(html, isProblem); }
function locSetAddError(msg){ locList.setAddError(msg); }
function locLoad(force){ return locList.load(force); }
function locRender(){ function locRender(){
const list = document.getElementById('loc-list'); const list = document.getElementById('loc-list');
const count = document.getElementById('loc-count'); const count = document.getElementById('loc-count');
@@ -1352,130 +1330,14 @@ function locRenderParentPicker(){
if(keep && Array.from(sel.options).some(o => o.value === keep)) sel.value = keep; if(keep && Array.from(sel.options).some(o => o.value === keep)) sel.value = keep;
} }
function locReport(res){ function locImport(dryRun){ locList.importText(dryRun); }
const bits = []; function locAdd(){ locList.add(); }
const problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0; function locPatch(id, patch, describe){ return locList.patch(id, patch, describe); }
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(){ document.addEventListener('DOMContentLoaded', function(){
const paste = document.getElementById('loc-paste');
const file = document.getElementById('loc-file');
const btn = id => document.getElementById(id); const btn = id => document.getElementById(id);
locList.wire();
if(btn('loc-file-btn')) btn('loc-file-btn').addEventListener('click', () => file && file.click()); matList.wire();
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'); const addName = btn('loc-add-name');
if(addName) addName.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); locAdd(); } }); if(addName) addName.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); locAdd(); } });
@@ -1961,7 +1823,7 @@ function updateStepUI(){
// The location list lives on the server, so it is fetched when the step is // 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 // actually opened rather than on every page load. locLoad() is idempotent and
// no-ops once it has the list. // no-ops once it has the list.
if(currentStep === 11 && typeof locLoad === 'function') locLoad(); if(currentStep === 11 && typeof locLoad === 'function'){ locLoad(); if(typeof matList !== 'undefined') matList.load(); }
if(currentStep === 12 && typeof renderSectionToggles === 'function') renderSectionToggles(); if(currentStep === 12 && typeof renderSectionToggles === 'function') renderSectionToggles();
// Update buttons // Update buttons
@@ -2105,6 +1967,93 @@ function validateStep(n){
return false; return false;
} }
// ── PROJECT MATERIAL LIST (D6 / T8.6) ─────────────────────────────────────────
// The CR-005 call, made again for materials: build the upload path now rather
// than wait for the master workbook. Same component as the location list -
// paste or file, dry-run check, rejected rows with source line numbers, and an
// editable list whose entries deactivate rather than delete. The field set is
// deliberately small: description, unit, an optional code. No inventory level,
// no price, no warehouse id - that is the deferred catalog, and it stays deferred.
const MATERIAL_SAMPLE = [
'Sample 3/4in EMT,FT,SAMPLE-EMT-075',
'Sample 1in EMT,FT,SAMPLE-EMT-100',
'Sample strut channel,FT,SAMPLE-STRUT',
'Sample junction box 4x4,EA',
].join('\n');
function matApi(suffix){
return '/api/projects/' + encodeURIComponent(locProjectId()) + '/materials' + (suffix || '');
}
const matList = WPListImport({
prefix: 'mat', api: matApi, projectId: locProjectId, sample: MATERIAL_SAMPLE,
loadKey: 'items', esc: locEsc,
render: matRender,
rejectedRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
duplicateRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
addPayload: function(){
const name = ((document.getElementById('mat-add-name') || {}).value || '').trim();
if(!name) return {error: 'Enter a description for the material you are adding.'};
const unit = ((document.getElementById('mat-add-unit') || {}).value || '').trim();
return {payload: {description: name, unit: unit, code: ''}};
},
addedMessage: body => `Added <code>${locEsc(body.description)}</code>.`,
noProjectMessage: 'Open this wizard from a project to configure its material list. '
+ 'The list is stored against the project on the server, not in this browser.',
});
function matRender(){
const list = document.getElementById('mat-list');
const count = document.getElementById('mat-count');
if(!list) return;
const rows = matList.state.rows;
const active = rows.filter(r => r.active);
if(count){
count.textContent = rows.length
? `${active.length} material${active.length===1?'':'s'} in use`
+ (rows.length > active.length ? `, ${rows.length - active.length} deactivated` : '')
: '';
}
if(!rows.length){
list.innerHTML = '<p class="field-hint">No material list yet. Requests fall back to free '
+ 'text until one is loaded - a project with no list can still raise a request.</p>';
return;
}
list.innerHTML = rows.map(r => `<div class="loc-row${r.active ? '' : ' is-off'}" data-id="${locEsc(r.id)}">
<span class="loc-level">${locEsc(r.unit || '—')}</span>
<input class="mat-desc" type="text" value="${locEsc(r.description)}"
aria-label="Description for ${locEsc(r.code || r.description)}" data-id="${locEsc(r.id)}">
${r.code ? `<code class="loc-code">${locEsc(r.code)}</code>` : ''}
<label class="loc-toggle"><input type="checkbox" class="mat-active" data-id="${locEsc(r.id)}"
${r.active ? 'checked' : ''}><span>In use</span></label>
</div>`).join('');
}
document.addEventListener('DOMContentLoaded', function(){
const list = document.getElementById('mat-list');
if(!list) return;
list.addEventListener('change', function(e){
const t = e.target;
if(!t) return;
if(t.classList.contains('mat-active')){
matList.patch(t.dataset.id, {active: t.checked},
b => b.active
? `<code>${locEsc(b.description)}</code> is back in use.`
: `<code>${locEsc(b.description)}</code> is no longer offered on new requests. `
+ 'Requests already referencing it are unchanged.');
return;
}
if(t.classList.contains('mat-desc')){
const row = matList.state.rows.find(n => n.id === t.dataset.id);
const next = (t.value || '').trim();
if(!row || next === row.description){ if(row) t.value = row.description; return; }
if(!next){ t.value = row.description; return; }
matList.patch(t.dataset.id, {description: next},
b => `Renamed to “${locEsc(b.description)}”.`);
}
});
});
// ── SOP COMPLETION ──────────────────────────────────────────────────────────── // ── SOP COMPLETION ────────────────────────────────────────────────────────────
// Re-saving a SOP that is already complete changes the project's baseline, which // Re-saving a SOP that is already complete changes the project's baseline, which
// the server restricts to a Project Admin. Check before doing the work so the // the server restricts to a Project Admin. Check before doing the work so the

View File

@@ -12,6 +12,7 @@
during their own boot. --> during their own boot. -->
<script src="wp-url.js"></script> <script src="wp-url.js"></script>
<script src="wp-usage.js"></script> <script src="wp-usage.js"></script>
<script src="wp-list-import.js"></script>
<!-- Autosave, unsaved-work guard, draft recovery (S2). --> <!-- Autosave, unsaved-work guard, draft recovery (S2). -->
<script src="wp-autosave.js"></script> <script src="wp-autosave.js"></script>
<!-- Which work package sections this project uses (CR-006). Shared with the <!-- Which work package sections this project uses (CR-006). Shared with the
@@ -466,6 +467,51 @@
<div class="field-error" id="loc-add-err" role="alert"></div> <div class="field-error" id="loc-add-err" role="alert"></div>
</div> </div>
</div> </div>
<!-- MATERIAL LIST (D6 / T8.6). Same component as the location list
above (wp-list-import.js), same rules: paste or file, dry-run
check, rejected rows named by source line, deactivate not
delete. Description, unit, optional code - and nothing else:
no inventory, no pricing, no warehouse id. -->
<div style="margin-top: 2.5rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
<div class="sub-heading">Material list (D6)</div>
<div class="step-desc">What a material request picks from (CR-013). Optional —
a project with no list still raises requests with free text.</div>
<div id="mat-noproject" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
<div id="mat-tool">
<div class="field-grid col1">
<div class="field">
<label for="mat-paste">Paste rows, or upload a CSV</label>
<textarea id="mat-paste" rows="5" aria-describedby="mat-paste-hint"
placeholder="One row per material, e.g.&#10;Description, unit, code"></textarea>
<small id="mat-paste-hint">One row per material:
<strong>description, unit, code</strong> — unit and code optional.
Comma, semicolon or tab separated. A header row is ignored.</small>
</div>
</div>
<div class="loc-actions">
<input type="file" id="mat-file" accept=".csv,.txt,text/csv,text/plain" hidden>
<button type="button" class="add-btn" id="mat-file-btn">Choose a CSV file…</button>
<button type="button" class="add-btn" id="mat-check-btn">Check without importing</button>
<button type="button" class="add-btn" id="mat-import-btn">Import</button>
<button type="button" class="add-btn" id="mat-sample-btn">Load sample values</button>
</div>
<div class="loc-report" id="mat-report" role="status"></div>
<div style="margin-top: 1.5rem;">
<div class="sub-heading">Current list</div>
<p class="field-hint" id="mat-count"></p>
<div id="mat-list"></div>
<div class="loc-addrow">
<label class="loc-addlabel" for="mat-add-name">Description</label>
<input type="text" id="mat-add-name" placeholder="e.g. a strut channel">
<label class="loc-addlabel" for="mat-add-unit">Unit</label>
<input type="text" id="mat-add-unit" placeholder="EA / FT" style="max-width:90px">
<button type="button" class="add-btn" id="mat-add-btn">+ Add</button>
</div>
<div class="field-error" id="mat-add-err" role="alert"></div>
</div>
</div>
</div>
</div> </div>
<!-- STEP 12: WORK PACKAGE SECTIONS (CR-006) <!-- STEP 12: WORK PACKAGE SECTIONS (CR-006)

210
html/wp-list-import.js Normal file
View File

@@ -0,0 +1,210 @@
/* The project-list import component — T5.4's machinery, extracted (D6 / T8.6).
One implementation of paste-or-file → server-side import with dry-run →
a report that names every rejected row with its SOURCE line number → an
editable list whose entries deactivate rather than delete. The location list
(CR-005) and the material list (D6) are both instances of this; building the
material path "the same way and against the same component, not beside it"
is the T8.6 instruction, and extracting the component is what makes that
literally true rather than a copy with the names changed.
The page supplies what differs: the API base, the sample, how a row renders,
and what the add-row collects. Everything generic — the file reader feeding
the paste box (one parser, on the server), the dry-run wiring, the report
roles (a report that lost rows interrupts; a clean one does not, per T4.5) —
lives here once.
Classic script, no modules: exposes window.WPListImport. */
'use strict';
(function () {
function el(id) { return document.getElementById(id); }
window.WPListImport = function (cfg) {
// cfg.prefix DOM id prefix: '<p>-paste', '<p>-file', '<p>-file-btn',
// '<p>-check-btn', '<p>-import-btn', '<p>-sample-btn',
// '<p>-report', '<p>-add-name', '<p>-add-btn', '<p>-add-err',
// '<p>-tool', '<p>-noproject'
// cfg.api(suffix) URL builder for the project-scoped routes
// cfg.projectId() current project id ('' = not opened from a project)
// cfg.sample text the sample button loads
// cfg.loadKey response key holding the rows ('nodes' | 'items')
// cfg.render() paints the current list from state.rows
// cfg.rejectedRow(r) <li> HTML for one rejected row
// cfg.duplicateRow(r) <li> HTML for one duplicate row
// cfg.addPayload() reads the add-row; {payload} to POST or {error}
// cfg.addedMessage(body) confirmation HTML after a successful add
// cfg.noProjectMessage what to say when there is no project
// cfg.esc the page's escaper
var p = cfg.prefix;
var esc = cfg.esc;
var state = { rows: [], loaded: false };
// Every message 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 say(html, isProblem) {
var box = el(p + '-report');
if (!box) return;
box.setAttribute('role', isProblem ? 'alert' : 'status');
box.innerHTML = html || '';
box.classList.toggle('is-problem', !!isProblem);
}
function setAddError(msg) {
var box = el(p + '-add-err');
if (box) box.textContent = msg || '';
var input = el(p + '-add-name');
if (input) {
if (msg) input.setAttribute('aria-invalid', 'true');
else input.removeAttribute('aria-invalid');
}
}
function load(force) {
var tool = el(p + '-tool');
var warn = el(p + '-noproject');
var pid = cfg.projectId();
if (!pid) {
if (tool) tool.style.display = 'none';
if (warn) { warn.style.display = ''; warn.textContent = cfg.noProjectMessage; }
return Promise.resolve();
}
if (tool) tool.style.display = '';
if (warn) warn.style.display = 'none';
if (state.loaded && !force) return Promise.resolve();
return fetch(cfg.api('?include_inactive=true'), { headers: { 'Accept': 'application/json' } })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (data) { state.rows = data[cfg.loadKey] || []; state.loaded = true; cfg.render(); })
.catch(function (err) {
state.loaded = false;
cfg.render();
say('⚠ Could not load the list — ' + esc((err && err.message) || 'offline')
+ '. It is stored on the server, so nothing local is shown in its place.', true);
});
}
function report(res) {
var bits = [];
var problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0;
var verb = res.dry_run ? 'would be added' : 'added';
var nCreated = (res.created || []).length;
bits.push('<p class="loc-report-line"><strong>' + res.read + ' row' + (res.read === 1 ? '' : 's')
+ ' read.</strong> ' + nCreated + ' value' + (nCreated === 1 ? '' : 's') + ' ' + verb
+ ((res.reactivated || []).length ? ', ' + res.reactivated.length + ' brought back into use' : '')
+ '.</p>');
var rowList = function (title, rows, fmt) {
if (!rows || !rows.length) return '';
return '<div class="loc-report-group"><div class="loc-report-title">' + esc(title)
+ ' (' + rows.length + ')</div><ul class="loc-report-list">' + rows.map(fmt).join('') + '</ul></div>';
};
bits.push(rowList('Rejected', res.rejected, cfg.rejectedRow));
bits.push(rowList('Duplicates, not merged', res.duplicates, cfg.duplicateRow));
if (!problem && !nCreated && !(res.reactivated || []).length) {
bits.push('<p class="loc-report-line">Nothing to do — every row is already on this project.</p>');
}
say(bits.join(''), problem);
}
function importText(dryRun) {
var text = (el(p + '-paste') || {}).value || '';
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
if (!cfg.projectId()) { load(); return; }
say('Checking…', false);
fetch(cfg.api('/import'), {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ text: text, dry_run: !!dryRun }),
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
.then(function (res) {
if (!res.ok) {
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
return;
}
report(res.body);
if (!dryRun) return load(true);
})
.catch(function (err) {
say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline')
+ '. Nothing was imported.', true);
});
}
function add() {
var read = cfg.addPayload();
if (read.error) {
setAddError(read.error);
var input = el(p + '-add-name');
if (input) input.focus();
return;
}
setAddError('');
fetch(cfg.api(''), {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(read.payload),
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
.then(function (res) {
if (!res.ok) {
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
return;
}
var input = el(p + '-add-name');
if (input) input.value = '';
say(cfg.addedMessage(res.body), false);
return load(true);
})
.catch(function (err) { setAddError('Could not reach the server — ' + ((err && err.message) || 'offline')); });
}
function patch(id, patchBody, describe) {
return fetch(cfg.api('/' + encodeURIComponent(id)), {
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(patchBody),
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
.then(function (res) {
if (!res.ok) {
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
return load(true);
}
say(describe(res.body), false);
return load(true);
})
.catch(function (err) {
say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline'), true);
});
}
function wire() {
var paste = el(p + '-paste');
var file = el(p + '-file');
var btn = function (suffix) { return el(p + suffix); };
if (btn('-file-btn')) btn('-file-btn').addEventListener('click', function () { if (file) file.click(); });
if (file) file.addEventListener('change', function (ev) {
var f = ev.target.files && ev.target.files[0];
if (!f) return;
var reader = new FileReader();
reader.onload = function () {
// 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 || '');
say('Read <strong>' + esc(f.name) + '</strong>. Check it, then import.', false);
};
reader.onerror = function () { say('⚠ Could not read that file.', true); };
reader.readAsText(f);
ev.target.value = '';
});
if (btn('-check-btn')) btn('-check-btn').addEventListener('click', function () { importText(true); });
if (btn('-import-btn')) btn('-import-btn').addEventListener('click', function () { importText(false); });
if (btn('-sample-btn')) btn('-sample-btn').addEventListener('click', function () {
if (paste) paste.value = cfg.sample;
say('Sample values loaded into the box — obviously fake, and safe to import '
+ 'on a throwaway project.', false);
});
if (btn('-add-btn')) btn('-add-btn').addEventListener('click', add);
}
return { state: state, say: say, setAddError: setAddError, load: load,
importText: importText, add: add, patch: patch, wire: wire };
};
})();

View File

@@ -0,0 +1,45 @@
"""per-project material list (D6 / T8.6)
CR-013 was written to accept free text because Nate's spreadsheet and the
master material workbook had not been supplied - and they still have not.
The Aug 18 call was the same one made for locations at CR-005: build the
upload path now. One row per line item a request can pick from: description,
unit, an optional code. Deliberately NO inventory level, price or warehouse
id - a project-scoped uploaded list is not the deferred parts catalog.
Additive only: a new table, no change to any existing one.
Revision ID: a1b8c6d4e2f9
Revises: f3a9d2c1e8b7
Create Date: 2026-08-19 15:40:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a1b8c6d4e2f9'
down_revision = 'f3a9d2c1e8b7'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'material_items',
sa.Column('id', sa.String(length=40), nullable=False),
sa.Column('project_id', sa.String(length=40), nullable=False),
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
sa.Column('description', sa.String(length=300), nullable=False, server_default=''),
sa.Column('unit', sa.String(length=20), nullable=False, server_default=''),
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_material_items_project_id', 'material_items', ['project_id'])
def downgrade() -> None:
op.drop_index('ix_material_items_project_id', table_name='material_items')
op.drop_table('material_items')

View File

@@ -2436,6 +2436,24 @@ def import_locations(project_id: str, body: LocationImportIn,
return result return result
class MaterialIn(BaseModel):
description: str = ""
unit: str = ""
code: str = ""
class MaterialPatchIn(BaseModel):
description: Optional[str] = None
unit: Optional[str] = None
code: Optional[str] = None
active: Optional[bool] = None
class MaterialImportIn(BaseModel):
text: str = ""
dry_run: bool = False
class LocationIn(BaseModel): class LocationIn(BaseModel):
level: str = "building" level: str = "building"
parent_id: Optional[str] = None parent_id: Optional[str] = None
@@ -2873,6 +2891,153 @@ def delete_wp_file(file_id: str, user: models.User = Depends(auth.get_current_us
return {"deleted": file_id, "used": used, "ceiling": FILE_PROJECT_CEILING} return {"deleted": file_id, "used": used, "ceiling": FILE_PROJECT_CEILING}
# ── Project material list (D6 / T8.6) - the CR-005 pattern, for materials ─────
def parse_material_rows(text: str):
"""description[,unit[,code]] per row - comma, semicolon or tab separated, a
paste straight out of a spreadsheet. A header row is ignored. Rejections come
back with the SOURCE line number: an import that says '42 rows' over a file
with 50 has lost eight and told nobody."""
rows, rejected = [], []
header_words = ("description", "desc", "item", "material")
for i, raw in enumerate((text or "").split(chr(10)), start=1):
# rstrip only: a LEADING separator means the first column is empty, and
# the first column is the description - eating it would accept ",FT" as
# a material named FT (found by the probe on the first run).
line = raw.strip().rstrip(",;")
if not line:
continue
parts = [p.strip() for p in re.split(r"[,;\t]", line)]
if i == 1 and parts and parts[0].lower() in header_words:
continue
parts = [p for p in parts]
if not parts or not parts[0]:
rejected.append({"line": i, "text": raw.strip()[:120],
"reason": "no description in the first column"})
continue
if len(parts) > 3:
rejected.append({"line": i, "text": raw.strip()[:120],
"reason": "more than three columns - description, unit, code is the whole shape"})
continue
rows.append((i, parts))
return rows, rejected
def material_key(desc: str, code: str) -> str:
return (code or "").strip().lower() or location_slug(desc).lower()
@app.get("/api/projects/{project_id}/materials")
def list_materials(project_id: str, include_inactive: bool = Query(False),
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
require_project_access(db, user, project_id)
stmt = select(models.MaterialItem).where(models.MaterialItem.project_id == project_id)
if not include_inactive:
stmt = stmt.where(models.MaterialItem.active.is_(True))
rows = db.scalars(stmt.order_by(models.MaterialItem.sort, models.MaterialItem.description)).all()
return {"items": [r.to_dict() for r in rows]}
@app.post("/api/projects/{project_id}/materials/import")
def import_materials(project_id: str, body: MaterialImportIn,
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
if not db.get(models.Project, project_id):
raise HTTPException(status_code=404, detail="Project not found")
require_project_access(db, user, project_id)
require_project_writable(db, user, project_id, "The material list cannot be changed")
rows, rejected = parse_material_rows(body.text)
existing = {material_key(r.description, r.code): r for r in db.scalars(
select(models.MaterialItem).where(models.MaterialItem.project_id == project_id)).all()}
created, duplicates, reactivated = [], [], []
seen_in_file = {}
next_sort = max((r.sort for r in existing.values()), default=0)
for line_no, parts in rows:
desc = parts[0][:300]
unit = (parts[1] if len(parts) > 1 else "")[:20].upper()
code = (parts[2] if len(parts) > 2 else "")[:80]
key = material_key(desc, code)
if key in seen_in_file:
duplicates.append({"line": line_no, "text": desc,
"reason": "already on line %d of this import" % seen_in_file[key]})
continue
seen_in_file[key] = line_no
if key in existing:
row = existing[key]
if not row.active:
if not body.dry_run:
row.active = True
reactivated.append({"text": desc})
else:
duplicates.append({"line": line_no, "text": desc,
"reason": "already on this project"})
continue
next_sort += 1
created.append({"description": desc, "unit": unit, "code": code})
if not body.dry_run:
item = models.MaterialItem(id=gen_id("mat"), project_id=project_id,
code=code, description=desc, unit=unit,
active=True, sort=next_sort)
db.add(item)
existing[key] = item
if not body.dry_run:
log_event(db, user, "materials_imported", "project", project_id,
project_id=project_id, summary="material list",
detail={"created": len(created), "rejected": len(rejected)})
db.commit()
return {"read": len(rows) + len(rejected), "created": created,
"rejected": rejected, "duplicates": duplicates,
"reactivated": reactivated, "dry_run": body.dry_run}
@app.post("/api/projects/{project_id}/materials")
def add_material(project_id: str, body: MaterialIn,
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
if not db.get(models.Project, project_id):
raise HTTPException(status_code=404, detail="Project not found")
require_project_access(db, user, project_id)
require_project_writable(db, user, project_id, "The material list cannot be changed")
desc = (body.description or "").strip()
if not desc:
raise HTTPException(status_code=400, detail="A description is required.")
key = material_key(desc, body.code)
clash = [r for r in db.scalars(select(models.MaterialItem)
.where(models.MaterialItem.project_id == project_id)).all()
if material_key(r.description, r.code) == key]
if clash:
raise HTTPException(status_code=409, detail="That material is already on this project.")
next_sort = (db.scalar(select(func.coalesce(func.max(models.MaterialItem.sort), 0))
.where(models.MaterialItem.project_id == project_id)) or 0) + 1
item = models.MaterialItem(id=gen_id("mat"), project_id=project_id,
code=(body.code or "").strip()[:80],
description=desc[:300],
unit=(body.unit or "").strip()[:20].upper(),
active=True, sort=next_sort)
db.add(item)
db.commit()
return item.to_dict()
@app.patch("/api/projects/{project_id}/materials/{item_id}")
def patch_material(project_id: str, item_id: str, body: MaterialPatchIn,
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
row = db.get(models.MaterialItem, item_id)
if not row or row.project_id != project_id:
raise HTTPException(status_code=404, detail="Material not found")
require_project_access(db, user, project_id)
require_project_writable(db, user, project_id, "The material list cannot be changed")
if body.description is not None:
row.description = body.description.strip()[:300]
if body.unit is not None:
row.unit = body.unit.strip()[:20].upper()
if body.code is not None:
row.code = body.code.strip()[:80]
if body.active is not None:
# Deactivate, never delete - a request already referencing the line must
# keep rendering it (the CR-005 rule, applied to materials).
row.active = bool(body.active)
db.commit()
return row.to_dict()
@app.get("/api/audit") @app.get("/api/audit")
def list_audit( def list_audit(
entity_type: Optional[str] = Query(None), entity_type: Optional[str] = Query(None),

View File

@@ -347,6 +347,32 @@ class AppSetting(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class MaterialItem(Base):
"""One line of a project's material list - D6 / T8.6, the CR-005 call made
again: build the upload path now rather than wait for the master workbook.
Deliberately small: description, unit, an optional code. NO inventory level,
NO price, NO warehouse id - a project-scoped list the project uploaded is
not the deferred parts catalog, and the moment a stock count appears here it
has crossed the line IMPLEMENTATION.md section 7 draws. `active` rather than
delete, same as everything else: a request already referencing a line must
keep rendering it."""
__tablename__ = "material_items"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
project_id: Mapped[str] = mapped_column(String(40), index=True)
code: Mapped[str] = mapped_column(String(80), default="")
description: Mapped[str] = mapped_column(String(300), default="")
unit: Mapped[str] = mapped_column(String(20), default="")
active: Mapped[bool] = mapped_column(Boolean, default=True)
sort: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
def to_dict(self) -> dict:
return {"id": self.id, "project_id": self.project_id, "code": self.code,
"description": self.description, "unit": self.unit,
"active": self.active, "sort": self.sort}
class WpFile(Base): class WpFile(Base):
"""CR-007 / D8: a drawing uploaded onto a work package. The BYTES live here, """CR-007 / D8: a drawing uploaded onto a work package. The BYTES live here,
in the same database as everything else - settled Aug 18: a backup that in the same database as everything else - settled Aug 18: a backup that

View File

@@ -344,8 +344,18 @@ def run(page, base, tok):
if not ln.strip().startswith(("//", "*", "/*"))) if not ln.strip().startswith(("//", "*", "/*")))
chk("...and never touches localStorage", "localStorage" not in code_only, chk("...and never touches localStorage", "localStorage" not in code_only,
[ln for ln in code_only.splitlines() if "localStorage" in ln][:3]) [ln for ln in code_only.splitlines() if "localStorage" in ln][:3])
# Re-pointed at T8.6, not relaxed: the fetches moved into the shared list
# component (wp-list-import.js) when the material list was built "against
# the same component" (D6). The proposition is unchanged - every read and
# write reaches the server - so it is asserted where the fetches now live,
# plus the wiring that sends THIS list's traffic there.
comp = open(os.path.join(ROOT, "html", "wp-list-import.js"), encoding="utf-8").read()
comp_code = chr(10).join(ln for ln in comp.splitlines()
if not ln.strip().startswith(("//", "*", "/*")))
chk("...reaching the server for every read and write", chk("...reaching the server for every read and write",
block.count("fetch(locApi") >= 4, block.count("fetch(locApi")) "api: locApi" in block and comp_code.count("fetch(cfg.api") >= 4
and "localStorage" not in comp_code,
(block.count("api: locApi"), comp_code.count("fetch(cfg.api")))
# The B100 floor/area list has not been supplied (IMPLEMENTATION.md section 8), # 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 # so any of these appearing as a location value would be a guess presented as

169
tests/materials_check.py Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Does the material list upload the way locations do? — D6, T8.6.
CR-013 accepted free text because the master workbook never arrived; the Aug 18
call was the CR-005 call again - build the upload path now. Same component as
the location list (wp-list-import.js - grep asserts there is exactly one),
same rules: paste or file, dry-run check, rejected rows named with their source
line, deactivate never delete. Description, unit, optional code - and the probe
greps that NO inventory, pricing or stock field crossed the line
IMPLEMENTATION.md section 7 draws.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from qa_gate_check import api # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.5):
time.sleep(seconds)
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
# ── 1. one component, no forbidden fields ─────────────────────────────────
print("\n1. grep: one component, the lightweight scope")
suite = open(os.path.join(HTML, "work-package-suite-app.js"), encoding="utf-8").read()
comp_defs = [n for n in os.listdir(HTML) if n.endswith(".js")
and "window.WPListImport" in open(os.path.join(HTML, n), encoding="utf-8").read()]
chk("the import component is defined once, in wp-list-import.js",
comp_defs == ["wp-list-import.js"], ascii_(comp_defs))
chk("locations and materials are both instances of it - not a copy beside it",
"locList = WPListImport(" in suite and "matList = WPListImport(" in suite
and "function locImport(dryRun){ locList.importText" in suite)
model = open(os.path.join(ROOT, "server", "models.py"), encoding="utf-8").read()
mat_block = model[model.find("class MaterialItem"):model.find("class WpFile")]
cols = re.findall(r"^\s+(\w+): Mapped", mat_block, re.M)
chk("no inventory, stock, price or warehouse column exists on the model - "
"the lightweight scope, exactly",
cols and not any(re.search(r"stock|inventor|price|on_hand|warehouse", c, re.I)
for c in cols), ascii_(cols))
chk("the sample rows are obviously fake",
"SAMPLE-EMT" in suite and "Sample " in suite)
tmpdir = tempfile.mkdtemp(prefix="wpsuite-mat-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
root = tok["root"]
# ── 2. the server: import, report, edit, deactivate ──────────────────
print("\n2. the routes")
text = "description,unit,code\nSample 3/4in EMT,FT,S-EMT\n,FT\nSample strut,FT\nSample 3/4in EMT,FT,S-EMT\n"
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
{"text": text, "dry_run": True})
chk("a dry run reports without writing",
code == 200 and rep["dry_run"] and len(rep["created"]) == 2, ascii_(rep))
chk("...rejected rows carry the SOURCE line and a reason",
rep["rejected"] and rep["rejected"][0]["line"] == 3
and "description" in rep["rejected"][0]["reason"], ascii_(rep["rejected"]))
chk("...in-file duplicates are named with the line they repeat",
rep["duplicates"] and "line 2" in rep["duplicates"][0]["reason"],
ascii_(rep["duplicates"]))
_, listing = api(base, "/api/projects/projA/materials", root)
chk("...and nothing was written", listing["items"] == [])
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
{"text": text, "dry_run": False})
_, listing = api(base, "/api/projects/projA/materials", root)
chk("the real import lands both good rows", len(listing["items"]) == 2,
ascii_(listing))
item = listing["items"][0]
code, _ = api(base, "/api/projects/projA/materials/%s" % item["id"], root,
"PATCH", {"active": False})
_, act = api(base, "/api/projects/projA/materials", root)
_, allrows = api(base, "/api/projects/projA/materials?include_inactive=true", root)
chk("deactivating hides a line from new requests without deleting it",
code == 200 and len(act["items"]) == 1 and len(allrows["items"]) == 2)
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
{"text": "Sample 3/4in EMT,FT,S-EMT", "dry_run": False})
chk("re-importing a deactivated line brings back the SAME row",
code == 200 and len(rep["reactivated"]) == 1, ascii_(rep))
code, added = api(base, "/api/projects/projA/materials", root, "POST",
{"description": "Sample anchor", "unit": "ea"})
chk("hand-adding works, unit normalised", code == 200 and added["unit"] == "EA")
# ── 3. the wizard at 390px ────────────────────────────────────────────
print("\n3. the wizard, on a phone")
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", root)
page.viewport(390, 844, mobile=True)
page.goto(base + "/work-package-suite.html?tab=sop&project=projA")
dismiss_dialogs(page)
settle(2.5)
page.eval("currentStep = 11; updateStepUI(); if(typeof matList!=='undefined') matList.load(); locLoad();")
settle(1.2)
chk("step 11 offers the material list beside the location list",
page.eval("!!document.getElementById('mat-tool') && !!document.getElementById('loc-tool')"))
chk("...its current list renders the server rows",
"Sample strut" in json.loads(page.eval(
"JSON.stringify([...document.querySelectorAll('#mat-list .mat-desc')]"
".map(i=>i.value))")), ascii_(json.loads(page.eval(
"JSON.stringify([...document.querySelectorAll('#mat-list .mat-desc')]"
".map(i=>i.value))"))))
chk("...and nothing scrolls sideways at 390px",
page.eval("document.documentElement.scrollWidth <= 392"),
page.eval("document.documentElement.scrollWidth"))
page.eval("document.getElementById('mat-paste').value='Sample wire nut,EA'")
page.eval("document.getElementById('mat-import-btn').click()")
settle(1.2)
chk("an import through the UI lands and reports",
"1 value added" in page.eval(
"(document.getElementById('mat-report')||{textContent:''}).textContent"),
ascii_(page.eval("(document.getElementById('mat-report')||{textContent:''}).textContent")))
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors anywhere in this run", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())