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:
@@ -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