T6.3/T6.4 - CR-004 and CR-018: picked not typed, and totals that add up
CR-004 and CR-018 are the same change seen from two ends. CR-018 is why the
Acumatica cost code came out rather than being relabelled — the tracking
dimension the team wants is floor and area, not an accounting code — and CR-004
is what makes that dimension exist. Committed together because a rollup keyed on
free text is not a rollup, and structured location with nothing rolling up by it
is a form change nobody asked for.
CR-004 - three dependent dropdowns
Building filters Floor filters Sector, off the project's own taxonomy from
T5.4. Clearing a parent clears its children: not doing that is how a package
ends up filed under a floor that is not in the building it claims.
PATHS are stored, not names and not bare codes. A floor's own code is not
unique across buildings; `B-ONE/L1` is. That is what lets the dashboard filter
by a building and match everything beneath it with a prefix test, and it is
what CR-018 groups on.
The list is fetched with include_inactive=true, which is not a contradiction of
CR-005's "deactivating hides it from new work packages" — they are two
questions. What may be CHOSEN is active only. What may be SHOWN is everything,
because a package already referencing a deactivated value still has to render
its label, and blanking it on open would write the blank back on the next save.
A deactivated value that IS on the package is offered, labelled "(no longer
offered)"; on a fresh package it is not offered at all. Both checked.
X5, checked the way aggregates_check checks its own: localStorage is poisoned
with a fake building and the dropdown is required to ignore it.
wp_location survives as a hidden field. A package written before this keeps
what it said, and the form says so rather than dropping it.
CR-018 - the rollup
LOCATION_DIMENSIONS is now ("building", "floor", "sector"). T4.1's note said
"only this tuple and the keys inside each group change - the response shape
does not", and that held exactly.
Rolled up at EVERY level, server-side, not just at the leaf. "How many on
floor 2" is the question CR-018 asks and it is a level above the leaf groups;
summing them in the browser would be the same per-browser arithmetic B4
removed. Actual Hours rolls up along the same dimensions - that is the field
CR-017 retained, and this is why that decision mattered.
Packages with no location are an explicit "(unassigned)" row, not a gap. The
reason is arithmetic: a group set that silently omits them does not add up to
the project total, and a rollup that does not reconcile is decoration. The
probe checks every level sums to the project total, and to the estimated and
actual hour totals, using distinct primes so a mis-sum cannot land on the
right number by luck.
A package with a building but no floor lands in the floor-level unassigned row
alongside the one with no location at all - which is the honest answer, and is
asserted by its hours rather than by its count.
Free text captured before CR-004 groups under itself as a building rather than
collapsing into unassigned, one level deep. Pretending free text is a
hierarchy would file "FAB / LVL 1" under a building called "FAB / LVL 1".
server/app.py dimensions, _location_levels, hours per group
html/wp-creation-index.html three selects where the text box was
html/wp-creation-app.js the pickers, the filters, the rollup panel
html/wp-creation-styles.css .loc-picker, .loc-rollup
tests/rollup_check.py new - 63 checks
Done when — CR-004
[x] all three render as dropdowns populated from project configuration
[x] dependent filtering works, and clearing a parent clears its children
[x] values persist as codes; confirmed by reading what collectPackage stored
[x] the dashboard filters by each of the three
[x] a work package referencing a deactivated value still renders correctly
[x] all option data comes from the server - proved by poisoning the cache
Done when — CR-018
[x] the dashboard groups and totals by Building, Floor and Sector
[x] totals reconcile against an unfiltered count, at every level
[x] Actual Hours rolls up along the same dimensions
[x] grouping is computed server-side - proved by putting nine fake packages in
localStorage and requiring the panel to show none of them
[x] work packages with no location appear in an explicit unassigned group
No migration: location lives in the work package's JSON data blob like every
other per-package field. No colour literal added.
Verified one at a time
rollup_check 63/63 new
generalinfo_check 49/49
browser_check 71/71
pipeline 43/43
a11y 22/22
aggregates 16/16
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
Question for the PR, per CLAUDE.md: the dashboard's location filters and the
rollup both key on the path, so a package saved with free text and no codes is
unreachable by any location filter and sits in its own building-level row. That
is correct and it is also a migration question - whether the existing free-text
locations should be mapped onto the taxonomy once the B100 list arrives, or left
as history. Nothing here decides it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
508
tests/rollup_check.py
Normal file
508
tests/rollup_check.py
Normal file
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structured location, and the rollup that is the point of it — CR-004, CR-018.
|
||||
|
||||
X5 makes both of these blocking on `B4`: rollup by building, floor and sector
|
||||
cannot come from localStorage. So the two checks that matter most are the ones
|
||||
about where the numbers and the option lists come from, and they are made the
|
||||
way `aggregates_check.py` makes its own — by poisoning the cache and demanding
|
||||
the server's answer.
|
||||
|
||||
CR-004 three dependent dropdowns, populated from project configuration
|
||||
clearing a parent clears its children
|
||||
values persist as CODES; confirmed by reading what was stored
|
||||
the dashboard filters by each of the three
|
||||
a package referencing a deactivated value still renders
|
||||
all option data comes from the server
|
||||
CR-018 the dashboard groups and totals by building, floor and sector
|
||||
totals reconcile against an unfiltered count
|
||||
Actual Hours rolls up along the same dimensions
|
||||
grouping is computed server-side
|
||||
packages with no location appear in an explicit unassigned group
|
||||
rather than vanishing
|
||||
|
||||
"Reconciles" is arithmetic, so it is checked as arithmetic: every level's rows
|
||||
must sum to the project total, including the unassigned row. A rollup that does
|
||||
not add up is decoration.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
# Not a real building. IMPLEMENTATION.md section 8 — the B100 list has not been
|
||||
# supplied, and a probe inventing one puts a guessed floor name in the repo just
|
||||
# as surely as the app would.
|
||||
TAXONOMY = "\n".join([
|
||||
"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",
|
||||
])
|
||||
|
||||
B1 = "PROBE-BUILDING-ONE"
|
||||
B2 = "PROBE-BUILDING-TWO"
|
||||
F11 = B1 + "/PROBE-LEVEL-1"
|
||||
F12 = B1 + "/PROBE-LEVEL-2"
|
||||
S1A = F11 + "/PROBE-SECTOR-A"
|
||||
S1B = F11 + "/PROBE-SECTOR-B"
|
||||
S2A = F12 + "/PROBE-SECTOR-A"
|
||||
|
||||
SOP_DATA = {
|
||||
"sop": {"meta": {"tool": "Work Package Configuration"},
|
||||
"project": {"name": "Job A", "number": "A-1"},
|
||||
"governance": {"disciplines": ["Electrical"], "woFormat": "WP##-[TYPE]"},
|
||||
"woTypes": [{"name": "Conduit Install", "enabled": True}]},
|
||||
"state": {"project": {"name": "Job A", "number": "A-1", "client": "Internal QA",
|
||||
"division": "Internal", "site": "QA Lab"},
|
||||
"team": {"pm": "", "apm": "", "cm": "", "qm": ""},
|
||||
"teamIds": {"pm": "", "apm": "", "cm": "", "qm": ""}, "teamMembers": [],
|
||||
"signoffRoles": [{"role": "Superintendent", "name": ""}],
|
||||
"wpTypes": [{"name": "Conduit Install", "enabled": True}],
|
||||
"governance": {"woformat": "WP##-[TYPE]", "wosize": "", "issuance": [],
|
||||
"disciplines": ["Electrical"], "discMode": "choice",
|
||||
"instanceSuffix": "letter", "sizeHoursMax": ""},
|
||||
"quality": {"qcreq": "Yes", "photo": "", "hold": ""},
|
||||
"platforms": {"tracking": "CxAlloy", "commissioning": "CxAlloy",
|
||||
"trackingUrl": "", "commissioningUrl": ""},
|
||||
"constraints": [], "sequence": [], "sources": []},
|
||||
}
|
||||
|
||||
# Six packages: four located, one with only a building, one with nothing at all.
|
||||
# Hours are distinct primes so a mis-summed total cannot land on the right number
|
||||
# by luck.
|
||||
WPS = [
|
||||
("wpL1", "WPL01", {"building": B1, "floor": F11, "sector": S1A,
|
||||
"hours": "2", "actualHrs": "3", "constraints": []}),
|
||||
("wpL2", "WPL02", {"building": B1, "floor": F11, "sector": S1B,
|
||||
"hours": "5", "actualHrs": "7", "constraints": []}),
|
||||
("wpL3", "WPL03", {"building": B1, "floor": F12, "sector": S2A,
|
||||
"hours": "11", "actualHrs": "13", "constraints": []}),
|
||||
("wpL4", "WPL04", {"building": B2, "floor": B2 + "/PROBE-LEVEL-1",
|
||||
"sector": B2 + "/PROBE-LEVEL-1/PROBE-SECTOR-A",
|
||||
"hours": "17", "actualHrs": "19", "constraints": []}),
|
||||
("wpL5", "WPL05", {"building": B1, "hours": "23", "actualHrs": "29",
|
||||
"constraints": []}), # building only, no floor
|
||||
("wpL6", "WPL06", {"hours": "31", "actualHrs": "37", "constraints": []}), # nothing
|
||||
]
|
||||
TOTAL = len(WPS)
|
||||
EST_TOTAL = 2 + 5 + 11 + 17 + 23 + 31
|
||||
ACT_TOTAL = 3 + 7 + 13 + 19 + 29 + 37
|
||||
|
||||
|
||||
def settle(seconds=1.2):
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
def js_errors(page):
|
||||
"""page.js_errors() minus two entries that are not this task's, and not
|
||||
errors:
|
||||
|
||||
/api/sops/latest 404 a project with no SOP answers 404 correctly, and the
|
||||
browser logs every 404 resource at error level
|
||||
beforeunload Chromium logs a refusal to show the unsaved-work
|
||||
prompt on a page with no user gesture. That is T4.3's
|
||||
guard working, it is present at wave 4 too (checked
|
||||
during T5.1 by capturing both sides), and it fires
|
||||
here because the probe navigates away from a form it
|
||||
has typed into."""
|
||||
skip = ("/api/sops/latest", "beforeunload")
|
||||
return [e for e in page.js_errors() if not any(s in e for s in skip)]
|
||||
|
||||
|
||||
def wait_creator(page, tries=40):
|
||||
for _ in range(tries):
|
||||
if page.eval("!!window.wpCreatorReady"):
|
||||
return True
|
||||
time.sleep(0.3)
|
||||
return False
|
||||
|
||||
|
||||
def seed_located(db_path):
|
||||
tok = seed(db_path)
|
||||
from server.db import SessionLocal
|
||||
from server import models
|
||||
with SessionLocal() as db:
|
||||
db.get(models.Sop, "sopA").data = SOP_DATA
|
||||
# The two fixture packages carry no location and would muddy the
|
||||
# arithmetic; this probe owns the whole set.
|
||||
for wid in ("wpA1", "wpA2"):
|
||||
row = db.get(models.WorkPackage, wid)
|
||||
if row:
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
for wid, num, data in WPS:
|
||||
db.add(models.WorkPackage(
|
||||
id=wid, project_id="projA", sop_id="sopA", number=num,
|
||||
subject=num.lower(), status="Draft", type="Conduit Install",
|
||||
data=dict(data, number=num, subject=num.lower(), status="Draft")))
|
||||
db.commit()
|
||||
return tok
|
||||
|
||||
|
||||
def api(page, method, path, body=None):
|
||||
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_creator(page, base, tok, query="?project=projA"):
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["root"])
|
||||
page.goto(base + "/wp-creation-index.html" + query)
|
||||
ok = wait_creator(page)
|
||||
settle(1.6)
|
||||
page.eval(STUB)
|
||||
return ok
|
||||
|
||||
|
||||
def sel_options(page, el_id):
|
||||
return json.loads(page.eval("""JSON.stringify((() => {
|
||||
const s = document.getElementById(%s);
|
||||
if (!s) return null;
|
||||
return {disabled: s.disabled, value: s.value,
|
||||
options: [...s.options].map(o => ({v: o.value, t: o.textContent.trim()}))};
|
||||
})())""" % json.dumps(el_id)))
|
||||
|
||||
|
||||
def run(page, base, tok):
|
||||
print("\nsetting up: import the taxonomy through the API this project uses")
|
||||
open_creator(page, base, tok)
|
||||
res = api(page, "POST", "/api/projects/projA/locations/import", {"text": TAXONOMY})
|
||||
chk("the taxonomy imports", res["status"] == 200 and len(res["body"]["created"]) == 9, res)
|
||||
|
||||
print("\nCR-004 (T6.3). Three dependent dropdowns, from the server")
|
||||
open_creator(page, base, tok)
|
||||
chk("the creator boots with no JavaScript error", not js_errors(page), js_errors(page))
|
||||
for level, el in (("building", "wp_building"), ("floor", "wp_floor"), ("sector", "wp_sector")):
|
||||
chk("%-8s renders as a <select>, not a text box" % level,
|
||||
page.eval("(document.getElementById(%s)||{}).tagName" % json.dumps(el)) == "SELECT")
|
||||
chk("the free-text box is gone from the form",
|
||||
page.eval("(document.getElementById('wp_location')||{}).type") == "hidden")
|
||||
|
||||
b = sel_options(page, "wp_building")
|
||||
chk("buildings come from the project's own configuration",
|
||||
[o["v"] for o in b["options"][1:]] == [B1, B2], b)
|
||||
chk("...showing their NAMES, storing their codes",
|
||||
[o["t"] for o in b["options"][1:]] == ["Probe Building One", "Probe Building Two"], b)
|
||||
chk("floor starts disabled, because nothing could sensibly be in it",
|
||||
sel_options(page, "wp_floor")["disabled"] is True)
|
||||
chk("...and so does sector", sel_options(page, "wp_sector")["disabled"] is True)
|
||||
|
||||
print(" dependent filtering")
|
||||
page.eval("""(() => {
|
||||
const s = document.getElementById('wp_building');
|
||||
s.value = %s; s.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
return true;
|
||||
})()""" % json.dumps(B1))
|
||||
settle(0.5)
|
||||
f = sel_options(page, "wp_floor")
|
||||
chk("choosing a building offers only its floors",
|
||||
[o["v"] for o in f["options"][1:]] == [F11, F12], f)
|
||||
chk("...and not the other building's",
|
||||
not [o for o in f["options"] if o["v"].startswith(B2)], f)
|
||||
page.eval("""(() => {
|
||||
const s = document.getElementById('wp_floor');
|
||||
s.value = %s; s.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
return true;
|
||||
})()""" % json.dumps(F11))
|
||||
settle(0.5)
|
||||
sec = sel_options(page, "wp_sector")
|
||||
chk("choosing a floor offers only its sectors",
|
||||
[o["v"] for o in sec["options"][1:]] == [S1A, S1B], sec)
|
||||
|
||||
print(" clearing a parent clears its children")
|
||||
page.eval("""(() => {
|
||||
const s = document.getElementById('wp_sector');
|
||||
s.value = %s; s.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
const b = document.getElementById('wp_building');
|
||||
b.value = ''; b.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
return true;
|
||||
})()""" % json.dumps(S1A))
|
||||
settle(0.5)
|
||||
chk("clearing the building clears the floor",
|
||||
page.eval("document.getElementById('wp_floor').value") == "")
|
||||
chk("...and the sector", page.eval("document.getElementById('wp_sector').value") == "")
|
||||
chk("...and both are disabled again, not left offering stale options",
|
||||
sel_options(page, "wp_floor")["disabled"] is True
|
||||
and sel_options(page, "wp_sector")["disabled"] is True)
|
||||
|
||||
print(" values persist as codes")
|
||||
stored = json.loads(page.eval("""(() => {
|
||||
const set = (id, v) => { const s = document.getElementById(id);
|
||||
s.value = v; s.dispatchEvent(new Event('change', {bubbles:true})); };
|
||||
set('wp_building', %s); set('wp_floor', %s); set('wp_sector', %s);
|
||||
const out = collectPackage();
|
||||
return JSON.stringify({building: out.building, floor: out.floor, sector: out.sector});
|
||||
})()""" % (json.dumps(B1), json.dumps(F11), json.dumps(S1B))))
|
||||
chk("what is stored is codes, not display strings",
|
||||
stored == {"building": B1, "floor": F11, "sector": S1B}, stored)
|
||||
chk("...and no display string leaked into any of them",
|
||||
not any("Probe Building One" == v for v in stored.values()), stored)
|
||||
|
||||
print(" a deactivated value still renders on a package that references it")
|
||||
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
||||
sec_b = [n for n in nodes if n["path"] == S1B][0]
|
||||
api(page, "PATCH", "/api/projects/projA/locations/" + sec_b["id"], {"active": False})
|
||||
open_creator(page, base, tok)
|
||||
page.eval("""(() => {
|
||||
loadPackageIntoForm({id:'x', number:'WPX', subject:'deactivated probe',
|
||||
building: %s, floor: %s, sector: %s, constraints: []});
|
||||
return true;
|
||||
})()""" % (json.dumps(B1), json.dumps(F11), json.dumps(S1B)))
|
||||
settle(0.6)
|
||||
chk("the package keeps its deactivated sector rather than being blanked",
|
||||
page.eval("document.getElementById('wp_sector').value") == S1B,
|
||||
page.eval("document.getElementById('wp_sector').value"))
|
||||
chk("...and the option says it is no longer offered",
|
||||
"no longer offered" in json.dumps(sel_options(page, "wp_sector")),
|
||||
sel_options(page, "wp_sector"))
|
||||
chk("...and it is NOT offered on a fresh package",
|
||||
page.eval("""(() => {
|
||||
newPackage();
|
||||
const b = document.getElementById('wp_building');
|
||||
b.value = %s; b.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
const f = document.getElementById('wp_floor');
|
||||
f.value = %s; f.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
return [...document.getElementById('wp_sector').options].map(o => o.value)
|
||||
.indexOf(%s) < 0;
|
||||
})()""" % (json.dumps(B1), json.dumps(F11), json.dumps(S1B))))
|
||||
api(page, "PATCH", "/api/projects/projA/locations/" + sec_b["id"], {"active": True})
|
||||
|
||||
print(" all option data comes from the server")
|
||||
src = open(os.path.join(ROOT, "html", "wp-creation-app.js"), encoding="utf-8").read()
|
||||
start = src.find("// ── LOCATION (CR-004 / T6.3)")
|
||||
end = src.find("// ── SECTION TOGGLES", start)
|
||||
block = src[start:end]
|
||||
code = "\n".join(ln for ln in block.splitlines() if not ln.strip().startswith("//"))
|
||||
chk("the location block exists", bool(block), [start, end])
|
||||
chk("...and never reads localStorage (X5)", "localStorage" not in code,
|
||||
[ln for ln in code.splitlines() if "localStorage" in ln][:2])
|
||||
chk("...it fetches the list from the locations endpoint",
|
||||
"/locations?include_inactive=true" in code)
|
||||
page.eval("""(() => {
|
||||
for (const k of ['wp_locations','wp_locations__projA','wp_location_list'])
|
||||
localStorage.setItem(k, JSON.stringify([{path:'FAKE', name:'Fake Building',
|
||||
level:'building', active:true}]));
|
||||
return true;
|
||||
})()""")
|
||||
open_creator(page, base, tok)
|
||||
chk("a poisoned cache puts nothing in the dropdown",
|
||||
"FAKE" not in json.dumps(sel_options(page, "wp_building")),
|
||||
sel_options(page, "wp_building"))
|
||||
page.eval("localStorage.removeItem('wp_locations'); "
|
||||
"localStorage.removeItem('wp_locations__projA'); "
|
||||
"localStorage.removeItem('wp_location_list'); true")
|
||||
|
||||
print("\nCR-018 (T6.4). The rollup, and whether it adds up")
|
||||
m = api(page, "GET", "/api/wps/metrics?project_id=projA")["body"]
|
||||
chk("the endpoint reports the six packages", m["total"] == TOTAL, m["total"])
|
||||
chk("the location dimensions are building, floor and sector",
|
||||
m["by_location"]["dimensions"] == ["building", "floor", "sector"],
|
||||
m["by_location"]["dimensions"])
|
||||
chk("...and it names the unassigned bucket rather than leaving it a magic string",
|
||||
m["by_location"].get("unassigned_key") == "(unassigned)", m["by_location"].get("unassigned_key"))
|
||||
levels = m["by_location"]["levels"]
|
||||
chk("every level is rolled up, not just the leaf",
|
||||
sorted(levels) == ["building", "floor", "sector"], sorted(levels))
|
||||
|
||||
for dim in ("building", "floor", "sector"):
|
||||
rows = levels[dim]
|
||||
chk("%-8s rows sum to the project total (%d)" % (dim, TOTAL),
|
||||
sum(r["total"] for r in rows) == TOTAL,
|
||||
[(r["path"], r["total"]) for r in rows])
|
||||
chk(" ...and its estimated hours to %d" % EST_TOTAL,
|
||||
sum(r["est_hours"] for r in rows) == EST_TOTAL,
|
||||
[(r["path"], r["est_hours"]) for r in rows])
|
||||
chk(" ...and its ACTUAL hours to %d, the CR-017 field CR-018 needs" % ACT_TOTAL,
|
||||
sum(r["actual_hours"] for r in rows) == ACT_TOTAL,
|
||||
[(r["path"], r["actual_hours"]) for r in rows])
|
||||
chk(" ...and nothing vanished: an unassigned row is present",
|
||||
any(r["path"] == "(unassigned)" for r in rows), [r["path"] for r in rows])
|
||||
|
||||
b_rows = {r["path"]: r for r in levels["building"]}
|
||||
chk("building one holds four packages", b_rows[B1]["total"] == 4, b_rows.get(B1))
|
||||
chk("building two holds one", b_rows[B2]["total"] == 1, b_rows.get(B2))
|
||||
chk("...and the one with no location at all is its own row",
|
||||
b_rows["(unassigned)"]["total"] == 1, b_rows.get("(unassigned)"))
|
||||
f_rows = {r["path"]: r for r in levels["floor"]}
|
||||
chk("the package with a building but no floor lands in the floor-level unassigned row",
|
||||
f_rows["(unassigned)"]["total"] == 2, f_rows.get("(unassigned)"))
|
||||
chk("...which is the honest answer: one has no location, one has no floor",
|
||||
f_rows["(unassigned)"]["actual_hours"] == 29 + 37, f_rows.get("(unassigned)"))
|
||||
chk("floor 1 of building one holds two", f_rows[F11]["total"] == 2, f_rows.get(F11))
|
||||
chk("...with their hours, not somebody else's",
|
||||
f_rows[F11]["actual_hours"] == 3 + 7, f_rows.get(F11))
|
||||
|
||||
print(" the grouping is the SERVER's, not the browser's")
|
||||
open_creator(page, base, tok)
|
||||
page.eval("""(() => {
|
||||
// Nine fake packages, all in one made-up building. If the panel were summed
|
||||
// in the browser this is what it would show.
|
||||
savedPackages = Array.from({length: 9}, (_, i) => ({
|
||||
id: 'fake' + i, number: 'FAKE0' + i, subject: 'fake', status: 'Draft',
|
||||
building: 'FAKE-BUILDING', hours: '99', actualHrs: '99', constraints: []}));
|
||||
saveStore(); showDashboard(); return true;
|
||||
})()""")
|
||||
for _ in range(30):
|
||||
if page.eval("!!document.querySelector('.loc-rollup')"):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
settle(1.2)
|
||||
panel = page.eval("""(() => {
|
||||
const p = [...document.querySelectorAll('.dash-panel')].find(x => {
|
||||
const t = x.querySelector('.dash-panel-title');
|
||||
return t && /^By location/.test(t.textContent.trim());
|
||||
});
|
||||
return p ? p.textContent : '';
|
||||
})()""")
|
||||
chk("the rollup panel renders", bool(panel and panel.strip()), (panel or "")[:80])
|
||||
chk("...and shows none of the nine fake packages", "FAKE-BUILDING" not in panel
|
||||
and "Fake" not in panel, panel[:200])
|
||||
chk("...it shows the server's buildings instead", "Probe Building One" in panel, panel[:200])
|
||||
chk("...and an explicit Unassigned row, not a gap", "Unassigned" in panel, panel[:300])
|
||||
chk("...with the totals the server computed",
|
||||
re.search(r"All buildings\s*%d" % TOTAL, panel.replace("\n", " ")) is not None,
|
||||
[ln for ln in panel.splitlines() if "All building" in ln][:1])
|
||||
|
||||
print(" the dashboard filters by each of the three")
|
||||
page.eval("""(() => {
|
||||
savedPackages = %s; saveStore(); renderDashboard(); return true;
|
||||
})()""" % json.dumps([dict(d, id=i, number=n, subject=n.lower(), type='Conduit Install')
|
||||
for i, n, d in WPS]))
|
||||
settle(0.8)
|
||||
|
||||
def board_rows():
|
||||
return json.loads(page.eval("""JSON.stringify((() => {
|
||||
const panel = [...document.querySelectorAll('.dash-panel')].find(p => {
|
||||
const t = p.querySelector('.dash-panel-title');
|
||||
return t && /^Work packages/.test(t.textContent.trim());
|
||||
});
|
||||
return [...panel.querySelectorAll('tbody tr')]
|
||||
.map(r => (r.cells[0]||{}).textContent || '').map(s => s.trim());
|
||||
})())"""))
|
||||
|
||||
chk("unfiltered, the board shows all six", len(board_rows()) == TOTAL, board_rows())
|
||||
page.eval("dashSetLocationFilter('building', %s)" % json.dumps(B1))
|
||||
settle(0.6)
|
||||
chk("filtering by building narrows to its four", sorted(board_rows())
|
||||
== ["WPL01", "WPL02", "WPL03", "WPL05"], board_rows())
|
||||
page.eval("dashSetLocationFilter('floor', %s)" % json.dumps(F11))
|
||||
settle(0.6)
|
||||
chk("...then by floor, to its two", sorted(board_rows()) == ["WPL01", "WPL02"], board_rows())
|
||||
page.eval("dashSetLocationFilter('sector', %s)" % json.dumps(S1A))
|
||||
settle(0.6)
|
||||
chk("...then by sector, to one", board_rows() == ["WPL01"], board_rows())
|
||||
page.eval("dashSetLocationFilter('building', '')")
|
||||
settle(0.6)
|
||||
chk("clearing the building clears the floor and sector filters with it",
|
||||
len(board_rows()) == TOTAL, board_rows())
|
||||
|
||||
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||[])"))
|
||||
|
||||
print("\n both widths")
|
||||
for w, label in ((390, "390px"), (1440, "1440px")):
|
||||
page.viewport(w, 900, mobile=(w == 390))
|
||||
open_creator(page, base, tok)
|
||||
chk("%s: the three dropdowns all render" % label, page.eval("""(() => {
|
||||
return ['wp_building','wp_floor','wp_sector'].every(id => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
});
|
||||
})()"""))
|
||||
chk("%s: and none of them is clipped by the viewport" % label, page.eval("""(() => {
|
||||
return ['wp_building','wp_floor','wp_sector'].every(id => {
|
||||
const r = document.getElementById(id).getBoundingClientRect();
|
||||
return r.left >= -1;
|
||||
});
|
||||
})()"""))
|
||||
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-rollup-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
try:
|
||||
tok = seed_located(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("\nStructured location and rollup — CR-004 / CR-018\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 — picked not typed, and the totals add up.", "32") + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user