Files
Project-SDE-WP-Suite/tests/locations_check.py
n.siegfried 190144c539 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>
2026-08-19 12:24:21 -07:00

451 lines
23 KiB
Python

#!/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")
# It was the last step when T5.4 shipped; T5.5 appended Sections after it. The
# thing that matters either way is that the wizard's own navigation follows —
# a step whose Next button is missing is a dead end, whichever number it has.
chk("...and the wizard's navigation follows it",
page.eval("getComputedStyle(document.getElementById('sop-next-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])
# 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",
"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),
# 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())