Files
Project-SDE-WP-Suite/tests/locations_check.py
n.siegfried 222c0b1c29 CR-005/D6 fix - a bad CSV row rejects by line number instead of 500ing Postgres
Nick's real location list hit the production import and got 'Internal Server
Error' with no line number - BL-027's class again, three days after the
migration outage: Postgres enforces VARCHAR lengths and refuses control
bytes, SQLite shrugs at both, and the importers were only ever rehearsed on
SQLite. Reproduced both hazards locally (an over-long value and a NUL byte
import cleanly on SQLite; either 500s Postgres wholesale).

Both importers now validate per row, before any INSERT, so every dialect
answers the same way - with the line number and a reason:
- locations: control characters; names over 200; codes over 60; combined
  paths over 200 (checked where the path exists, with read-counts taken
  before the loop so a mid-loop rejection is not counted twice)
- materials: control characters; description/unit/code over 300/20/80

And the client stops lying about it: wp-list-import.js read every response
with r.json(), so a plain-text 500 threw mid-parse and surfaced as 'Could not
reach the server' while the server was answering fine. One tolerant reader
(text -> parse if it parses -> keep status) now serves import, add and patch;
a real error reads 'Import refused - HTTP 500'.

Pins: materials_check +2 (over-long and control-byte rows reject at line,
20/20), locations_check +1 (over-long name rejects at line, 59/59).

Items: CR-005, D6, BL-027 (second instance of its class; the probe-side
dialect guard it proposes is still open).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:26:37 -07:00

458 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"])
# The 2026-08-23 production 500, pinned (locations side): Postgres-refused
# values reject by line, on every dialect, never crash the request.
hz = api(page, "POST", "/api/projects/projA/locations/import",
{"text": "Probe Building One," + "Y" * 220 + ",S1", "dry_run": True})
chk("an over-long name is a line rejection, not a 500",
hz["status"] == 200 and hz["body"]["rejected"]
and "200 characters" in hz["body"]["rejected"][0]["reason"], hz["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())