Files
Project-SDE-WP-Suite/tests/rollup_check.py
n.siegfried 4d186ea6a0 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>
2026-08-17 00:24:00 -05:00

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())