Files
Project-SDE-WP-Suite/tests/locations_check.py
n.siegfried 2081c1ad3c 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>
2026-08-16 11:06:27 -05:00

438 lines
22 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")
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())