T5.4 - CR-005: a per-project location taxonomy, stored as codes
CLAUDE.md lists CR-005 among the change requests that get "silently half-built if
you treat them as frontend-only". This is the server half and the wizard half
together: a new table, four routes, an Alembic revision, and step 11.
CODES, NOT DISPLAY STRINGS, because CR-018 rolls cost up by these values and a
rollup keyed on a label breaks the day somebody fixes a typo in it. Two columns
carry that: `code` is a node's own slug, derived once at import and never
recomputed; `path` is the full slug path, unique per project, and is what a work
package will store. Renaming a value changes `name` alone - the probe renames a
floor and demands its path comes back byte-identical, with its children's paths
intact.
DEACTIVATE, NEVER DELETE. There is no DELETE route, and the probe checks for its
absence (405) rather than trusting that nobody added one. Deactivating hides a
value from new work packages and cascades DOWN, because a floor nobody can pick
must not keep offering its sectors. Reactivating walks UP only - a sector may
have been switched off for its own reasons, and silently resurrecting it would
undo a decision nobody made twice. That asymmetry is deliberate and is pinned by
a named check so it does not get "fixed" into a surprise.
Import reports rather than merges. Rejected rows come back with the SOURCE line
number and a reason; duplicates are listed as duplicates, separated into "already
in this project" and "already on line N of this import". Reusing a parent is not
a duplicate - B1/L2/1P and B1/L2/2P share a building and a floor by design, and
only the full path repeating counts. Re-importing a deactivated value brings the
same row back rather than creating a second one; the probe checks the id.
One parser, on the server. A CSV is read in the browser and posted as text
exactly as a paste is, so "what does a blank column mean" has one answer.
Comma, semicolon and tab all work - a paste out of a spreadsheet is tab
separated and a saved CSV is not, and which one somebody has is a question the
machine can answer.
No guessed floor names. IMPLEMENTATION.md section 8 says the B100 list has not
been supplied. The seeded sample has "Sample" inside every string, and the probe
greps html/ and server/ for a location-shaped assignment containing any of the
review's real names.
server/models.py LocationNode
server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
server/app.py GET/POST/PATCH + import, parser, slug
html/work-package-suite.html step 11, an 11th rail button
html/work-package-suite-app.js the step's logic; LAST_STEP replaces 10
html/work-package-suite-styles.css the list, the report
html/theme-light.css .field-error, now declared once
tests/locations_check.py new - 58 checks
tests/stepper_check.py STEP_COUNT 10 -> 11
Done when
[x] CSV upload and paste both work and report rejected rows with reasons
[x] duplicates are detected and reported rather than silently merged
[x] values are editable after import - rename, add, deactivate
[x] deactivating hides it from new work packages; an existing package
referencing it still resolves, because the row is retained
[x] values are stored as codes suitable for grouping
[x] no guessed real-world floor names exist anywhere in the code
Two decisions worth disagreeing with
Step 11, appended, not step 2, inserted. Locations belong beside Project by
subject. Renumbering 2-10 would touch every sop-step-N id, every
collectStepData case, every gate key and the analytics history - a large
silent-mismatch surface for an ordering change. The count now lives in one
place (LAST_STEP), so reordering later is cheap.
Any project member may edit the list, not only a Project Admin. It matches how
the SOP baseline itself is authored: the Project Admin gate is on CHANGING a
completed SOP, not on writing one. If the location list should be tighter than
the SOP it belongs to, that is a product call.
Verified one at a time
locations_check 58/58 new
stepper_check 70/70 (11 steps)
browser_check 71/71
a11y 22/22 sop now rings 38 focusable elements
url_state 23/23
autosave 34/34
aggregates 16/16
pipeline 43/43
launcher 58/58
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
alembic upgrade / downgrade / upgrade all clean on a throwaway SQLite
file, and the migrated schema matches Base.metadata.create_all
column for column - dev auto-creates and production migrates,
so a divergence between the two is invisible until it ships
.field-error was declared in two page sheets by the end of T5.2 and would have
been three by T5.8, so it moved to theme-light.css. No colour literal added
anywhere: still 0 across all page sheets and inline blocks.
Question for the PR, per CLAUDE.md: the levels are fixed at building / floor /
sector. Micron's floors behave like buildings, which this handles by letting a
project use whichever levels it needs - but a job that wants a fourth level, or
different names for the three, cannot say so. Whether that is worth a
per-project level vocabulary is a product question; the schema would take it
without a migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
437
tests/locations_check.py
Normal file
437
tests/locations_check.py
Normal file
@@ -0,0 +1,437 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Per-project Building / Floor / Sector taxonomy — CR-005 (T5.4).
|
||||
|
||||
CLAUDE.md names this as one of the change requests that gets "silently half-built
|
||||
if you treat it as frontend-only": it needs structured location storage and
|
||||
aggregate endpoints, not localStorage. So the API is tested directly as well as
|
||||
through the wizard, and one check greps the wizard's own code for a localStorage
|
||||
write behind the list.
|
||||
|
||||
1. CSV upload and paste both work, and report rejected rows WITH reasons
|
||||
2. duplicates are detected and reported, not silently merged
|
||||
3. values are editable after import — rename and add
|
||||
4. deactivating hides a value from new work packages; a package already
|
||||
referencing it still resolves
|
||||
5. values are stored as codes suitable for grouping, and renaming does not
|
||||
move the code
|
||||
6. no guessed real-world floor name exists anywhere in the code
|
||||
|
||||
Check 5 is the one with teeth. `CR-018` rolls cost up by these values, so the
|
||||
grouping key has to survive a rename — the probe renames a node and demands its
|
||||
path is byte-identical afterwards.
|
||||
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import cdp # noqa: E402
|
||||
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
STUB = """
|
||||
window.__dialogs = [];
|
||||
window.alert = function (m) { window.__dialogs.push(['alert', String(m)]); };
|
||||
window.confirm = function (m) { window.__dialogs.push(['confirm', String(m)]); return false; };
|
||||
window.prompt = function (m) { window.__dialogs.push(['prompt', String(m)]); return null; };
|
||||
true
|
||||
"""
|
||||
|
||||
# Deliberately not a real building. IMPLEMENTATION.md section 8: the B100 floor
|
||||
# and area list has not been supplied, and a probe that invented one would put a
|
||||
# guessed Micron floor name in the repository just as surely as the app would.
|
||||
GOOD = "\n".join([
|
||||
"Building,Floor,Sector",
|
||||
"Probe Building One,Probe Level 1,Probe Sector A",
|
||||
"Probe Building One,Probe Level 1,Probe Sector B",
|
||||
"Probe Building One,Probe Level 2,Probe Sector A",
|
||||
"Probe Building Two,Probe Level 1,Probe Sector A",
|
||||
])
|
||||
|
||||
MESSY = "\n".join([
|
||||
"Probe Building One,Probe Level 1,Probe Sector A", # duplicate of the above
|
||||
",Probe Level 9,Probe Sector Z", # no building
|
||||
"Probe Building Three,,Probe Sector Q", # sector with no floor
|
||||
"Probe Building Three,Probe Level 1,Probe Sector A,too many",
|
||||
"---,Probe Level 1", # no code can be made
|
||||
"Probe Building Three,Probe Level 1,Probe Sector A", # genuinely new
|
||||
"Probe Building Three,Probe Level 1,Probe Sector A", # duplicate within the file
|
||||
])
|
||||
|
||||
|
||||
def settle(seconds=1.4):
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
def api(page, method, path, body=None):
|
||||
"""Call the API from inside the page, so the session cookie rides along."""
|
||||
js = """(() => {
|
||||
const opts = {method: %s, headers: {'Accept':'application/json'}};
|
||||
%s
|
||||
return fetch(%s, opts).then(r => r.text().then(t => JSON.stringify(
|
||||
{status: r.status, body: (() => { try { return JSON.parse(t); } catch (e) { return t; } })()})));
|
||||
})()""" % (
|
||||
json.dumps(method),
|
||||
("opts.headers['Content-Type']='application/json'; opts.body=%s;" % json.dumps(json.dumps(body)))
|
||||
if body is not None else "",
|
||||
json.dumps(path),
|
||||
)
|
||||
return json.loads(page.eval(js))
|
||||
|
||||
|
||||
def open_step11(page, base, tok, project="projA"):
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["root"])
|
||||
page.goto(base + "/work-package-suite.html?project=%s&step=11" % project)
|
||||
settle(2.0)
|
||||
page.eval(STUB)
|
||||
|
||||
|
||||
def rows_in_list(page):
|
||||
return json.loads(page.eval("""JSON.stringify(
|
||||
[...document.querySelectorAll('#loc-list .loc-row')].map(r => ({
|
||||
level: (r.querySelector('.loc-level')||{}).textContent,
|
||||
name: (r.querySelector('.loc-name')||{}).value,
|
||||
code: (r.querySelector('.loc-code')||{}).textContent,
|
||||
active: !!(r.querySelector('.loc-active')||{}).checked,
|
||||
off: r.classList.contains('is-off'),
|
||||
})))"""))
|
||||
|
||||
|
||||
def run(page, base, tok):
|
||||
print("\n1. paste imports, and reports what it refused")
|
||||
open_step11(page, base, tok)
|
||||
chk("the wizard has an eleventh step and it is showing",
|
||||
page.eval("getComputedStyle(document.getElementById('sop-step-11')).display") == "block",
|
||||
page.eval("(document.getElementById('sop-step-11')||{}).id"))
|
||||
chk("...named Locations in the rail",
|
||||
page.eval("(document.querySelector('#step-rail-list .step-btn[data-step=\"11\"] "
|
||||
".step-btn-label')||{}).textContent") == "Locations")
|
||||
chk("...and it is the last step, so SOP complete moved with it",
|
||||
page.eval("getComputedStyle(document.getElementById('sop-complete-btn')).display")
|
||||
!= "none")
|
||||
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
||||
|
||||
page.eval("document.getElementById('loc-paste').value = %s" % json.dumps(GOOD))
|
||||
page.eval("document.getElementById('loc-check-btn').click()")
|
||||
settle(1.2)
|
||||
report = page.eval("(document.getElementById('loc-report')||{}).textContent||''")
|
||||
# Four, not five: the header line is recognised and skipped. Asserted at the
|
||||
# number rather than "greater than zero", because "read 4 of a 5-line file" is
|
||||
# exactly the kind of quiet loss this report exists to make visible.
|
||||
chk("checking without importing reads every data row, header skipped",
|
||||
"4 rows read" in report, repr(report[:120]))
|
||||
chk("...and says what it would create without creating it",
|
||||
"9 values would be added" in report, repr(report[:120]))
|
||||
chk("...and writes nothing", api(page, "GET", "/api/projects/projA/locations")
|
||||
["body"]["nodes"] == [], api(page, "GET", "/api/projects/projA/locations")["body"])
|
||||
|
||||
page.eval("document.getElementById('loc-import-btn').click()")
|
||||
settle(1.6)
|
||||
listed = rows_in_list(page)
|
||||
chk("importing creates the whole hierarchy, parents included", len(listed) == 9,
|
||||
[r["code"] for r in listed])
|
||||
chk("...two buildings", sum(1 for r in listed if r["level"] == "building") == 2,
|
||||
[r["code"] for r in listed])
|
||||
chk("...three floors", sum(1 for r in listed if r["level"] == "floor") == 3,
|
||||
[r["code"] for r in listed])
|
||||
chk("...four sectors", sum(1 for r in listed if r["level"] == "sector") == 4,
|
||||
[r["code"] for r in listed])
|
||||
chk("...and a shared parent is created once, not once per row",
|
||||
[r["code"] for r in listed].count("PROBE-BUILDING-ONE") == 1,
|
||||
[r["code"] for r in listed])
|
||||
|
||||
print("\n5. the values are codes, and a rename does not move one")
|
||||
codes = [r["code"] for r in listed]
|
||||
chk("every value carries a slug path, not a display string",
|
||||
all(re.fullmatch(r"[A-Z0-9-]+(/[A-Z0-9-]+)*", c) for c in codes), codes)
|
||||
chk("...with the hierarchy in the path, which is what CR-018 groups by",
|
||||
"PROBE-BUILDING-ONE/PROBE-LEVEL-1/PROBE-SECTOR-A" in codes, codes)
|
||||
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
||||
target = [n for n in nodes if n["path"] == "PROBE-BUILDING-ONE/PROBE-LEVEL-2"][0]
|
||||
renamed = api(page, "PATCH", "/api/projects/projA/locations/" + target["id"],
|
||||
{"name": "Renamed Level"})
|
||||
chk("renaming a value succeeds", renamed["status"] == 200, renamed)
|
||||
chk("...changes the label", renamed["body"]["name"] == "Renamed Level", renamed["body"])
|
||||
chk("...and leaves the code EXACTLY as it was",
|
||||
renamed["body"]["path"] == target["path"] and renamed["body"]["code"] == target["code"],
|
||||
[target["path"], renamed["body"]["path"]])
|
||||
kids = [n for n in api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
||||
if n["path"].startswith(target["path"] + "/")]
|
||||
chk("...and its children's codes with it, so nothing beneath is orphaned",
|
||||
kids and all(k["path"].startswith("PROBE-BUILDING-ONE/PROBE-LEVEL-2/") for k in kids),
|
||||
[k["path"] for k in kids])
|
||||
|
||||
print("\n2. duplicates are reported, not merged")
|
||||
page.eval("document.getElementById('loc-paste').value = %s" % json.dumps(MESSY))
|
||||
page.eval("document.getElementById('loc-import-btn').click()")
|
||||
settle(1.6)
|
||||
report = page.eval("(document.getElementById('loc-report')||{}).textContent||''")
|
||||
chk("the report names duplicates as duplicates", "Duplicates, not merged" in report,
|
||||
repr(report[:200]))
|
||||
chk("...one already in the project, one repeated inside the file",
|
||||
"already in this project" in report and "already on line" in report, repr(report[:300]))
|
||||
chk("...and rejected rows separately", "Rejected" in report, repr(report[:200]))
|
||||
chk("...naming the reason for each", "no building" in report and "no floor above it" in report,
|
||||
repr(report[:400]))
|
||||
chk("...and the line number, so it can be found in the file",
|
||||
re.search(r"row \d+", report) is not None, repr(report[:200]))
|
||||
chk("the report interrupts when it lost rows (T4.5)",
|
||||
page.eval("document.getElementById('loc-report').getAttribute('role')") == "alert")
|
||||
after = rows_in_list(page)
|
||||
chk("a duplicate row created nothing", len(after) == 9 + 3,
|
||||
[r["code"] for r in after])
|
||||
chk("...while the genuinely new rows in the same file did import",
|
||||
"PROBE-BUILDING-THREE/PROBE-LEVEL-1/PROBE-SECTOR-A" in [r["code"] for r in after],
|
||||
[r["code"] for r in after])
|
||||
|
||||
print("\n1b. a CSV file goes through the same parser as a paste")
|
||||
page.eval("document.getElementById('loc-paste').value = ''")
|
||||
page.eval("""(() => {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File([%s], 'probe-locations.csv', {type: 'text/csv'}));
|
||||
const el = document.getElementById('loc-file');
|
||||
el.files = dt.files;
|
||||
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
return true;
|
||||
})()""" % json.dumps("Probe Building Four,Probe Level 1,Probe Sector A\n"))
|
||||
for _ in range(20):
|
||||
if page.eval("!!document.getElementById('loc-paste').value"):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
chk("choosing a file fills the same box a paste does",
|
||||
"Probe Building Four" in page.eval("document.getElementById('loc-paste').value"),
|
||||
page.eval("document.getElementById('loc-paste').value")[:80])
|
||||
chk("...and says which file it read",
|
||||
"probe-locations.csv" in page.eval(
|
||||
"(document.getElementById('loc-report')||{}).textContent||''"))
|
||||
page.eval("document.getElementById('loc-import-btn').click()")
|
||||
settle(1.6)
|
||||
chk("...and importing it works the same way",
|
||||
"PROBE-BUILDING-FOUR/PROBE-LEVEL-1/PROBE-SECTOR-A"
|
||||
in [r["code"] for r in rows_in_list(page)],
|
||||
[r["code"] for r in rows_in_list(page)])
|
||||
|
||||
print("\n3. the list is editable after import")
|
||||
page.eval("""(() => {
|
||||
document.getElementById('loc-add-parent').value = '';
|
||||
const n = document.getElementById('loc-add-name');
|
||||
n.value = 'Probe Building Five';
|
||||
document.getElementById('loc-add-btn').click();
|
||||
return true;
|
||||
})()""")
|
||||
settle(1.4)
|
||||
chk("a value can be added by hand",
|
||||
"PROBE-BUILDING-FIVE" in [r["code"] for r in rows_in_list(page)],
|
||||
[r["code"] for r in rows_in_list(page)])
|
||||
page.eval("""(() => {
|
||||
const n = document.getElementById('loc-add-name');
|
||||
n.value = ' ';
|
||||
document.getElementById('loc-add-btn').click();
|
||||
return true;
|
||||
})()""")
|
||||
settle(0.6)
|
||||
chk("...an empty one is refused at the field, not in a dialog",
|
||||
bool((page.eval("(document.getElementById('loc-add-err')||{}).textContent||''")).strip()),
|
||||
page.eval("(document.getElementById('loc-add-err')||{}).textContent||''"))
|
||||
chk("...announced through a live region",
|
||||
page.eval("document.getElementById('loc-add-err').getAttribute('role')") == "alert")
|
||||
dup = api(page, "POST", "/api/projects/projA/locations",
|
||||
{"level": "building", "name": "Probe Building Five"})
|
||||
chk("...and adding one that already exists is refused with a reason",
|
||||
dup["status"] == 409 and "already" in str(dup["body"].get("detail", "")).lower(), dup)
|
||||
orphan = api(page, "POST", "/api/projects/projA/locations",
|
||||
{"level": "sector", "name": "Probe Loose Sector"})
|
||||
chk("...a sector with nothing above it is refused",
|
||||
orphan["status"] == 400 and "floor" in str(orphan["body"].get("detail", "")).lower(),
|
||||
orphan)
|
||||
|
||||
print("\n4. deactivating hides a value without breaking what references it")
|
||||
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
||||
floor = [n for n in nodes if n["path"] == "PROBE-BUILDING-ONE/PROBE-LEVEL-1"][0]
|
||||
off = api(page, "PATCH", "/api/projects/projA/locations/" + floor["id"], {"active": False})
|
||||
chk("a value can be deactivated", off["status"] == 200 and off["body"]["active"] is False, off)
|
||||
|
||||
live = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
||||
live_paths = [n["path"] for n in live]
|
||||
chk("...and disappears from the list new work packages choose from",
|
||||
floor["path"] not in live_paths, live_paths)
|
||||
chk("...taking its sectors with it, so nothing offers a path to nowhere",
|
||||
not [p for p in live_paths if p.startswith(floor["path"] + "/")], live_paths)
|
||||
chk("...while its building stays offered", "PROBE-BUILDING-ONE" in live_paths, live_paths)
|
||||
|
||||
all_nodes = api(page, "GET",
|
||||
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
||||
kept = [n for n in all_nodes if n["path"] == floor["path"]]
|
||||
chk("...the row itself is RETAINED, not deleted", len(kept) == 1, [n["path"] for n in all_nodes])
|
||||
chk("...so a work package already referencing it still resolves its label",
|
||||
kept and kept[0]["name"] == floor["name"], kept)
|
||||
chk("...and there is no DELETE route at all to lose it by",
|
||||
api(page, "DELETE", "/api/projects/projA/locations/" + floor["id"])["status"] == 405,
|
||||
api(page, "DELETE", "/api/projects/projA/locations/" + floor["id"]))
|
||||
|
||||
open_step11(page, base, tok)
|
||||
shown = rows_in_list(page)
|
||||
chk("the wizard still shows the deactivated value, so it can be brought back",
|
||||
any(r["code"] == floor["path"] and r["off"] for r in shown),
|
||||
[(r["code"], r["off"]) for r in shown])
|
||||
page.eval("""(() => {
|
||||
const row = [...document.querySelectorAll('#loc-list .loc-row')]
|
||||
.find(r => (r.querySelector('.loc-code')||{}).textContent === %s);
|
||||
const box = row.querySelector('.loc-active');
|
||||
box.checked = true;
|
||||
box.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
return true;
|
||||
})()""" % json.dumps(floor["path"]))
|
||||
settle(1.4)
|
||||
back = [n["path"] for n in api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]]
|
||||
chk("ticking it puts it back in use", floor["path"] in back, back)
|
||||
chk("...on the SAME row, so nothing that referenced it was orphaned",
|
||||
[n for n in api(page, "GET",
|
||||
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
||||
if n["path"] == floor["path"]][0]["id"] == floor["id"])
|
||||
|
||||
print("\n re-importing a deactivated value brings it back rather than duplicating it")
|
||||
all_now = api(page, "GET",
|
||||
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
||||
live_now = [n["path"] for n in
|
||||
api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]]
|
||||
sec = [n for n in all_now if n["path"].endswith("/PROBE-SECTOR-B")][0]
|
||||
# Deliberate asymmetry, pinned here so nobody "fixes" it into a surprise:
|
||||
# deactivating a floor takes its sectors down with it, but ticking the floor
|
||||
# back on does NOT resurrect them. A sector may have been switched off for its
|
||||
# own reasons, and silently bringing it back would undo a decision nobody made
|
||||
# twice. They stay listed, struck through, one tick away.
|
||||
chk("reactivating a parent does not silently resurrect its children",
|
||||
sec["path"] not in live_now, [sec["path"], live_now])
|
||||
chk("...and they are still listed, so they can be brought back deliberately",
|
||||
sec["active"] is False)
|
||||
api(page, "PATCH", "/api/projects/projA/locations/" + sec["id"], {"active": False})
|
||||
again = api(page, "POST", "/api/projects/projA/locations/import",
|
||||
{"text": "Probe Building One,Probe Level 1,Probe Sector B"})
|
||||
chk("the import reports it as reactivated, not created or duplicate",
|
||||
len(again["body"]["reactivated"]) == 1 and not again["body"]["created"], again["body"])
|
||||
same = [n for n in api(page, "GET",
|
||||
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
||||
if n["path"] == sec["path"]]
|
||||
chk("...and reuses the same row", len(same) == 1 and same[0]["id"] == sec["id"], same)
|
||||
|
||||
print("\n6. no guessed real-world floor names, and no localStorage behind the list")
|
||||
chk("no native dialog was opened anywhere in this flow",
|
||||
not json.loads(page.eval("JSON.stringify(window.__dialogs||[])")),
|
||||
page.eval("JSON.stringify(window.__dialogs||[])"))
|
||||
src_js = open(os.path.join(ROOT, "html", "work-package-suite-app.js"), encoding="utf-8").read()
|
||||
start = src_js.find("// ── STEP 11: LOCATION LIST")
|
||||
end = src_js.find("// ── STEP GATES", start) if start >= 0 else -1
|
||||
block = src_js[start:end] if start >= 0 and end > start else ""
|
||||
chk("the locations code exists as its own bounded block", bool(block), [start, end])
|
||||
# Comments stripped first: this block QUOTES CLAUDE.md's rule about localStorage,
|
||||
# and a probe that failed on the sentence forbidding the thing rather than on the
|
||||
# thing would be the least useful possible false positive.
|
||||
code_only = "\n".join(ln for ln in block.splitlines()
|
||||
if not ln.strip().startswith(("//", "*", "/*")))
|
||||
chk("...and never touches localStorage", "localStorage" not in code_only,
|
||||
[ln for ln in code_only.splitlines() if "localStorage" in ln][:3])
|
||||
chk("...reaching the server for every read and write",
|
||||
block.count("fetch(locApi") >= 4, block.count("fetch(locApi"))
|
||||
|
||||
# The B100 floor/area list has not been supplied (IMPLEMENTATION.md section 8),
|
||||
# so any of these appearing as a location value would be a guess presented as
|
||||
# data. Checked over the whole tree, not only the file this task touched.
|
||||
GUESSES = ("B100", "1P", "2P", "Fab 7", "Level 2 chase")
|
||||
hits = []
|
||||
for folder in ("html", "server"):
|
||||
for name in sorted(os.listdir(os.path.join(ROOT, folder))):
|
||||
if not name.endswith((".js", ".html", ".py", ".css")):
|
||||
continue
|
||||
text = open(os.path.join(ROOT, folder, name), encoding="utf-8").read()
|
||||
# Only where it would be a location VALUE — the review quotes "1P" in
|
||||
# prose all over the place, and a comment is not a hardcoded floor.
|
||||
for m in re.finditer(r"(building|floor|sector|location)\w*\s*[:=]\s*['\"]([^'\"]{1,40})",
|
||||
text, re.I):
|
||||
if any(g.lower() in m.group(2).lower() for g in GUESSES):
|
||||
hits.append("%s/%s: %s" % (folder, name, m.group(0)[:60]))
|
||||
chk("no guessed real-world floor or sector name is assigned anywhere", not hits, hits[:4])
|
||||
sample = re.search(r"const LOCATION_SAMPLE = \[(.*?)\]", src_js, re.S)
|
||||
chk("the seeded sample values are obviously fake", bool(sample)
|
||||
and all("Sample" in ln for ln in re.findall(r"'([^']+)'", sample.group(1))),
|
||||
sample.group(1)[:200] if sample else None)
|
||||
|
||||
print("\n both widths")
|
||||
for w, label in ((390, "390px"), (1440, "1440px")):
|
||||
page.viewport(w, 900, mobile=(w == 390))
|
||||
open_step11(page, base, tok)
|
||||
chk("%s: the location step renders" % label,
|
||||
page.eval("document.querySelectorAll('#loc-list .loc-row').length") > 0)
|
||||
chk("%s: the page does not scroll sideways" % label,
|
||||
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
|
||||
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
|
||||
page.viewport(1400, 1000)
|
||||
|
||||
|
||||
def main():
|
||||
exe = cdp.find_browser()
|
||||
if not exe:
|
||||
print("no headless-capable browser found; set WP_BROWSER.")
|
||||
return 2
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-locations-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
if server is None:
|
||||
print("the test server would not start.")
|
||||
return 2
|
||||
print("\nLocation taxonomy — CR-005\nTarget: %s" % base)
|
||||
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
try:
|
||||
run(page, base, tok)
|
||||
finally:
|
||||
page.close()
|
||||
browser.close()
|
||||
finally:
|
||||
if server:
|
||||
server.kill()
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
try:
|
||||
from server.db import engine
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
import shutil
|
||||
for _ in range(10):
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
if not os.path.exists(tmpdir):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
|
||||
total = len(_PASS) + len(_FAIL)
|
||||
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
|
||||
if _FAIL:
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1
|
||||
print("\nResult: " + _c("ALL PASS — codes not labels, nothing merged, nothing deleted.", "32") + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -9,7 +9,7 @@ detached from the control it described.
|
||||
Every one of T5.1's done-whens is checked here, because nothing that already
|
||||
existed could check any of them:
|
||||
|
||||
1. all ten steps are <button> elements
|
||||
1. every step is a <button> element (ten until T5.4 appended Locations)
|
||||
2. the rail is operable by tab, arrow keys, Home/End, Enter and Space
|
||||
3. the current step is exposed with aria-current
|
||||
4. complete / current / unavailable are distinguishable WITHOUT colour
|
||||
@@ -51,6 +51,10 @@ HTML_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__
|
||||
# Wave 0 recorded 12. T5.1 must take ten of them.
|
||||
BASELINE_DIV_ONCLICK = 12
|
||||
|
||||
# Ten until T5.4 (CR-005) appended Locations. Named rather than repeated, so the
|
||||
# next step to be added moves one number instead of eight assertions.
|
||||
STEP_COUNT = 11
|
||||
|
||||
# Swallow the wizard's remaining native dialogs and record that they fired. The
|
||||
# wizard still has them until T5.8; this proves the RAIL never reaches one.
|
||||
STUB = """
|
||||
@@ -196,10 +200,11 @@ def run(page, base, tok):
|
||||
page.eval(STUB)
|
||||
|
||||
# ── 1. every step is a real button ────────────────────────────────────────
|
||||
print("\n1. all ten steps are <button> elements")
|
||||
print("\n1. every step is a <button> element")
|
||||
open_wizard()
|
||||
rows = rail(page)
|
||||
chk("the rail renders ten steps", len(rows) == 10, "found %d" % len(rows))
|
||||
chk("the rail renders %d steps" % STEP_COUNT, len(rows) == STEP_COUNT,
|
||||
"found %d" % len(rows))
|
||||
chk("...every one of them a <button>", rows and all(r["tag"] == "BUTTON" for r in rows),
|
||||
[r["tag"] for r in rows])
|
||||
chk('...with type="button", so none of them submits anything',
|
||||
@@ -218,8 +223,8 @@ def run(page, base, tok):
|
||||
chk("no #total-steps element", page.eval("!document.getElementById('total-steps')"))
|
||||
chk("no .step-counter anywhere on the page",
|
||||
page.eval("!document.querySelector('.step-counter')"))
|
||||
chk("...and no bare 'N / 10' left in the app bar",
|
||||
not re.search(r"\b\d+\s*/\s*10\b",
|
||||
chk("...and no bare 'N / %d' left in the app bar" % STEP_COUNT,
|
||||
not re.search(r"\b\d+\s*/\s*%d\b" % STEP_COUNT,
|
||||
page.eval("(document.querySelector('.header')||{}).textContent||''")),
|
||||
page.eval("(document.querySelector('.header')||{}).textContent||''")[:120])
|
||||
|
||||
@@ -246,7 +251,8 @@ def run(page, base, tok):
|
||||
# a fresh load and everything ahead of it is genuinely out of reach.
|
||||
locked = [r for r in rows if r["ariaDisabled"] == "true"]
|
||||
chk("with step 1 incomplete, steps 2-10 are unavailable",
|
||||
[r["step"] for r in locked] == list(range(2, 11)), [r["step"] for r in locked])
|
||||
[r["step"] for r in locked] == list(range(2, STEP_COUNT + 1)),
|
||||
[r["step"] for r in locked])
|
||||
chk("...each saying 'Locked' in words, not only in colour",
|
||||
locked and all(r["state"] == "Locked" for r in locked),
|
||||
list({r["state"] for r in locked}))
|
||||
@@ -300,7 +306,7 @@ def run(page, base, tok):
|
||||
page.eval("(document.activeElement.dataset||{}).step"))
|
||||
press(page, "End")
|
||||
chk("End jumps to the last step",
|
||||
page.eval("(document.activeElement.dataset||{}).step") == "10",
|
||||
page.eval("(document.activeElement.dataset||{}).step") == str(STEP_COUNT),
|
||||
page.eval("(document.activeElement.dataset||{}).step"))
|
||||
press(page, "Home")
|
||||
chk("Home jumps to the first",
|
||||
@@ -332,7 +338,7 @@ def run(page, base, tok):
|
||||
# have just filled in is worse than the strip it replaced.
|
||||
rows = rail(page)
|
||||
chk("emptying a required field re-locks the steps ahead, with no navigation",
|
||||
[r["step"] for r in rows if r["ariaDisabled"] == "true"] == list(range(2, 11)),
|
||||
[r["step"] for r in rows if r["ariaDisabled"] == "true"] == list(range(2, STEP_COUNT + 1)),
|
||||
[r["step"] for r in rows if r["ariaDisabled"] == "true"])
|
||||
chk("...and the guard refuses the same move",
|
||||
page.eval("(() => { const n = currentStep; goToStep(4); return currentStep === n; })()"))
|
||||
@@ -426,9 +432,9 @@ def run(page, base, tok):
|
||||
page.eval("(document.getElementById('step-rail-toggle')||{}).textContent||''"))
|
||||
page.click("#step-rail-toggle")
|
||||
settle(page, 0.5)
|
||||
chk("tapping it reveals all ten steps",
|
||||
chk("tapping it reveals every step",
|
||||
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
||||
".filter(b => b.getBoundingClientRect().height > 0).length") == 10,
|
||||
".filter(b => b.getBoundingClientRect().height > 0).length") == STEP_COUNT,
|
||||
page.eval("[...document.querySelectorAll('#step-rail-list .step-btn')]"
|
||||
".filter(b => b.getBoundingClientRect().height > 0).length"))
|
||||
chk("...and flips aria-expanded", page.eval(
|
||||
|
||||
Reference in New Issue
Block a user