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>
This commit is contained in:
169
tests/materials_check.py
Normal file
169
tests/materials_check.py
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does the material list upload the way locations do? — D6, T8.6.
|
||||
|
||||
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. Same component as
|
||||
the location list (wp-list-import.js - grep asserts there is exactly one),
|
||||
same rules: paste or file, dry-run check, rejected rows named with their source
|
||||
line, deactivate never delete. Description, unit, optional code - and the probe
|
||||
greps that NO inventory, pricing or stock field crossed the line
|
||||
IMPLEMENTATION.md section 7 draws.
|
||||
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
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 # noqa: E402
|
||||
from qa_gate_check import api # noqa: E402
|
||||
from stepper_check import dismiss_dialogs # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
HTML = os.path.join(ROOT, "html")
|
||||
|
||||
|
||||
def ascii_(v, n=280):
|
||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||
|
||||
|
||||
def settle(seconds=0.5):
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
def main():
|
||||
exe = cdp.find_browser()
|
||||
if not exe:
|
||||
print("no headless-capable browser found; set WP_BROWSER.")
|
||||
return 2
|
||||
|
||||
# ── 1. one component, no forbidden fields ─────────────────────────────────
|
||||
print("\n1. grep: one component, the lightweight scope")
|
||||
suite = open(os.path.join(HTML, "work-package-suite-app.js"), encoding="utf-8").read()
|
||||
comp_defs = [n for n in os.listdir(HTML) if n.endswith(".js")
|
||||
and "window.WPListImport" in open(os.path.join(HTML, n), encoding="utf-8").read()]
|
||||
chk("the import component is defined once, in wp-list-import.js",
|
||||
comp_defs == ["wp-list-import.js"], ascii_(comp_defs))
|
||||
chk("locations and materials are both instances of it - not a copy beside it",
|
||||
"locList = WPListImport(" in suite and "matList = WPListImport(" in suite
|
||||
and "function locImport(dryRun){ locList.importText" in suite)
|
||||
model = open(os.path.join(ROOT, "server", "models.py"), encoding="utf-8").read()
|
||||
mat_block = model[model.find("class MaterialItem"):model.find("class WpFile")]
|
||||
cols = re.findall(r"^\s+(\w+): Mapped", mat_block, re.M)
|
||||
chk("no inventory, stock, price or warehouse column exists on the model - "
|
||||
"the lightweight scope, exactly",
|
||||
cols and not any(re.search(r"stock|inventor|price|on_hand|warehouse", c, re.I)
|
||||
for c in cols), ascii_(cols))
|
||||
chk("the sample rows are obviously fake",
|
||||
"SAMPLE-EMT" in suite and "Sample " in suite)
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-mat-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
browser = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
root = tok["root"]
|
||||
|
||||
# ── 2. the server: import, report, edit, deactivate ──────────────────
|
||||
print("\n2. the routes")
|
||||
text = "description,unit,code\nSample 3/4in EMT,FT,S-EMT\n,FT\nSample strut,FT\nSample 3/4in EMT,FT,S-EMT\n"
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": text, "dry_run": True})
|
||||
chk("a dry run reports without writing",
|
||||
code == 200 and rep["dry_run"] and len(rep["created"]) == 2, ascii_(rep))
|
||||
chk("...rejected rows carry the SOURCE line and a reason",
|
||||
rep["rejected"] and rep["rejected"][0]["line"] == 3
|
||||
and "description" in rep["rejected"][0]["reason"], ascii_(rep["rejected"]))
|
||||
chk("...in-file duplicates are named with the line they repeat",
|
||||
rep["duplicates"] and "line 2" in rep["duplicates"][0]["reason"],
|
||||
ascii_(rep["duplicates"]))
|
||||
_, listing = api(base, "/api/projects/projA/materials", root)
|
||||
chk("...and nothing was written", listing["items"] == [])
|
||||
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": text, "dry_run": False})
|
||||
_, listing = api(base, "/api/projects/projA/materials", root)
|
||||
chk("the real import lands both good rows", len(listing["items"]) == 2,
|
||||
ascii_(listing))
|
||||
item = listing["items"][0]
|
||||
code, _ = api(base, "/api/projects/projA/materials/%s" % item["id"], root,
|
||||
"PATCH", {"active": False})
|
||||
_, act = api(base, "/api/projects/projA/materials", root)
|
||||
_, allrows = api(base, "/api/projects/projA/materials?include_inactive=true", root)
|
||||
chk("deactivating hides a line from new requests without deleting it",
|
||||
code == 200 and len(act["items"]) == 1 and len(allrows["items"]) == 2)
|
||||
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
|
||||
{"text": "Sample 3/4in EMT,FT,S-EMT", "dry_run": False})
|
||||
chk("re-importing a deactivated line brings back the SAME row",
|
||||
code == 200 and len(rep["reactivated"]) == 1, ascii_(rep))
|
||||
code, added = api(base, "/api/projects/projA/materials", root, "POST",
|
||||
{"description": "Sample anchor", "unit": "ea"})
|
||||
chk("hand-adding works, unit normalised", code == 200 and added["unit"] == "EA")
|
||||
|
||||
# ── 3. the wizard at 390px ────────────────────────────────────────────
|
||||
print("\n3. the wizard, on a phone")
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", root)
|
||||
page.viewport(390, 844, mobile=True)
|
||||
page.goto(base + "/work-package-suite.html?tab=sop&project=projA")
|
||||
dismiss_dialogs(page)
|
||||
settle(2.5)
|
||||
page.eval("currentStep = 11; updateStepUI(); if(typeof matList!=='undefined') matList.load(); locLoad();")
|
||||
settle(1.2)
|
||||
chk("step 11 offers the material list beside the location list",
|
||||
page.eval("!!document.getElementById('mat-tool') && !!document.getElementById('loc-tool')"))
|
||||
chk("...its current list renders the server rows",
|
||||
"Sample strut" in json.loads(page.eval(
|
||||
"JSON.stringify([...document.querySelectorAll('#mat-list .mat-desc')]"
|
||||
".map(i=>i.value))")), ascii_(json.loads(page.eval(
|
||||
"JSON.stringify([...document.querySelectorAll('#mat-list .mat-desc')]"
|
||||
".map(i=>i.value))"))))
|
||||
chk("...and nothing scrolls sideways at 390px",
|
||||
page.eval("document.documentElement.scrollWidth <= 392"),
|
||||
page.eval("document.documentElement.scrollWidth"))
|
||||
page.eval("document.getElementById('mat-paste').value='Sample wire nut,EA'")
|
||||
page.eval("document.getElementById('mat-import-btn').click()")
|
||||
settle(1.2)
|
||||
chk("an import through the UI lands and reports",
|
||||
"1 value added" in page.eval(
|
||||
"(document.getElementById('mat-report')||{textContent:''}).textContent"),
|
||||
ascii_(page.eval("(document.getElementById('mat-report')||{textContent:''}).textContent")))
|
||||
|
||||
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
|
||||
chk("no JavaScript errors anywhere in this run", not js_errors,
|
||||
ascii_(js_errors[:2]))
|
||||
|
||||
finally:
|
||||
if browser is not None:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
if server is not None:
|
||||
try:
|
||||
server.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user