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>
509 lines
23 KiB
Python
509 lines
23 KiB
Python
#!/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())
|