#!/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