X4 first, because the brief asks for it explicitly.
IMPLEMENTATION.md sequences CR-006 after B7 on the grounds that the toggles must
suppress sections inside the creator, which is an iframe child until T7.1. The
wave file puts it in wave 5 anyway, and its last done-when is written to
accommodate exactly that: "toggle state propagates into the creator, OR the PR
documents exactly where it does not and why".
It propagates. Both ways, by two separate paths, because they fail differently:
ON THE CREATOR'S OWN BOOT the flags ride on the SOP, which the creator already
reads - ProjectData.pullProject hydrates it from the server. Nothing crosses
the frame boundary at all, so this path is unaffected by B7 either way. It
covers a reload, a fresh tab, the standalone creator page, and a colleague
opening the project on another machine.
WHILE THE FRAME IS OPEN the wizard hands the change straight across
(pushSectionsToCreator -> cw.applySopSections), the same shape T5.3 used for
the dashboard filter. Without it, flipping a toggle would appear to do nothing
until a reload.
T7.1 removes the second path, not the first. That is the whole of the X4
exposure and it is one function, commented as such. Building CR-006 after B7
would not have made the SOP-borne path any different; it would only have saved
writing the hand-off.
What it does
Ten sections, one shared list (html/wp-sections.js) read by the wizard, the
creator's form and the creator's rendered document. Three surfaces meant three
chances to drift, which is how "Assets is off, except in the export" happens.
Off means NOT RENDERED - form, detail view and PDF export. It never means
deleted. renderPackage() was rebuilt from one long string into a list of
(section, html) blocks so a suppressed section leaves no empty heading and the
survivors renumber 1.0, 2.0, 3.0 instead of leaving a hole. The print window
reuses that same HTML, so the export needed no separate change.
Absent means ON. A SOP saved before today mentions no sections, and reading
that as "all off" would blank every project in the estate the moment this
shipped. WPSections.normalize is the one place that decision lives.
html/wp-sections.js new - the shared list, defaults, normalize
html/work-package-suite.html step 12, a 12th rail button
html/work-package-suite-app.js the toggles, state.sections, the hand-off
html/work-package-suite-styles.css the toggle rows
html/wp-creation-index.html stable ids on the five unnamed cards
html/wp-creation-app.js WP_SECTION_NODES, applySopSections,
renderPackage rebuilt as blocks
tests/sections_check.py new - 53 checks
Done when
[x] all 10 sections are toggleable - each one driven off and back on
[x] a section toggled off is absent from the form, the detail view and the
PDF export - checked by content marker, not by heading, so "the section
is gone" and "the section was empty" cannot be confused
[x] toggling off then on restores prior data with no loss - and the sharper
version: a package EDITED while Assets is off still carries its assets
through collectPackage(), which is what Save uses
[x] new SOPs default to all sections on
[x] toggle state propagates into the creator - both paths, separately
Two things worth arguing with
General Information is toggleable, because CR-006 enumerates it. Turning it
off leaves nothing to identify a package by. The row says so in its own note
rather than being quietly excluded from the list.
Location has no card of its own - it is a field inside General Information's
grid, and its toggle governs that one row. CR-004 gives it structured
building/floor/sector fields in wave 6, at which point one line of
WP_SECTION_NODES changes. Written down because "the toggle does nothing" and
"the toggle governs one row" look identical from outside.
Approvals & Sign-offs is NOT toggleable and is not one of the ten. A package
nobody signed is not a shorter package.
Verified one at a time
sections_check 53/53 new
stepper_check 70/70 (STEP_COUNT 11 -> 12)
locations_check 58/58 (its "step 11 is last" check now asserts the thing that
is actually invariant - the wizard's navigation follows)
browser_check 71/71
a11y 22/22
url_state 23/23
autosave 34/34
aggregates 16/16
pipeline 43/43
launcher 58/58
f_items F1-F5 FIXED, F6 REPRODUCES (T7.2)
No colour literal added: still 0 across all page sheets and inline blocks. Each
toggle says its state three ways - the checkbox, the words "In use" / "Not used",
and the rule down its left edge (C1).
Question for the PR, per CLAUDE.md: the toggles are SOP-wide, so a project
cannot use Kitting on install packages and not on BIM ones. BL-000b already
records the field-level version of this question; the per-WP-type version is the
same shape and is not recorded anywhere yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
441 lines
22 KiB
Python
441 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Per-project Building / Floor / Sector taxonomy — CR-005 (T5.4).
|
|
|
|
CLAUDE.md names this as one of the change requests that gets "silently half-built
|
|
if you treat it as frontend-only": it needs structured location storage and
|
|
aggregate endpoints, not localStorage. So the API is tested directly as well as
|
|
through the wizard, and one check greps the wizard's own code for a localStorage
|
|
write behind the list.
|
|
|
|
1. CSV upload and paste both work, and report rejected rows WITH reasons
|
|
2. duplicates are detected and reported, not silently merged
|
|
3. values are editable after import — rename and add
|
|
4. deactivating hides a value from new work packages; a package already
|
|
referencing it still resolves
|
|
5. values are stored as codes suitable for grouping, and renaming does not
|
|
move the code
|
|
6. no guessed real-world floor name exists anywhere in the code
|
|
|
|
Check 5 is the one with teeth. `CR-018` rolls cost up by these values, so the
|
|
grouping key has to survive a rename — the probe renames a node and demands its
|
|
path is byte-identical afterwards.
|
|
|
|
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
|
|
"""
|
|
|
|
# Deliberately not a real building. IMPLEMENTATION.md section 8: the B100 floor
|
|
# and area list has not been supplied, and a probe that invented one would put a
|
|
# guessed Micron floor name in the repository just as surely as the app would.
|
|
GOOD = "\n".join([
|
|
"Building,Floor,Sector",
|
|
"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",
|
|
])
|
|
|
|
MESSY = "\n".join([
|
|
"Probe Building One,Probe Level 1,Probe Sector A", # duplicate of the above
|
|
",Probe Level 9,Probe Sector Z", # no building
|
|
"Probe Building Three,,Probe Sector Q", # sector with no floor
|
|
"Probe Building Three,Probe Level 1,Probe Sector A,too many",
|
|
"---,Probe Level 1", # no code can be made
|
|
"Probe Building Three,Probe Level 1,Probe Sector A", # genuinely new
|
|
"Probe Building Three,Probe Level 1,Probe Sector A", # duplicate within the file
|
|
])
|
|
|
|
|
|
def settle(seconds=1.4):
|
|
time.sleep(seconds)
|
|
|
|
|
|
def api(page, method, path, body=None):
|
|
"""Call the API from inside the page, so the session cookie rides along."""
|
|
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_step11(page, base, tok, project="projA"):
|
|
page.clear_cookies()
|
|
page.set_cookie("wp_session", tok["root"])
|
|
page.goto(base + "/work-package-suite.html?project=%s&step=11" % project)
|
|
settle(2.0)
|
|
page.eval(STUB)
|
|
|
|
|
|
def rows_in_list(page):
|
|
return json.loads(page.eval("""JSON.stringify(
|
|
[...document.querySelectorAll('#loc-list .loc-row')].map(r => ({
|
|
level: (r.querySelector('.loc-level')||{}).textContent,
|
|
name: (r.querySelector('.loc-name')||{}).value,
|
|
code: (r.querySelector('.loc-code')||{}).textContent,
|
|
active: !!(r.querySelector('.loc-active')||{}).checked,
|
|
off: r.classList.contains('is-off'),
|
|
})))"""))
|
|
|
|
|
|
def run(page, base, tok):
|
|
print("\n1. paste imports, and reports what it refused")
|
|
open_step11(page, base, tok)
|
|
chk("the wizard has an eleventh step and it is showing",
|
|
page.eval("getComputedStyle(document.getElementById('sop-step-11')).display") == "block",
|
|
page.eval("(document.getElementById('sop-step-11')||{}).id"))
|
|
chk("...named Locations in the rail",
|
|
page.eval("(document.querySelector('#step-rail-list .step-btn[data-step=\"11\"] "
|
|
".step-btn-label')||{}).textContent") == "Locations")
|
|
# It was the last step when T5.4 shipped; T5.5 appended Sections after it. The
|
|
# thing that matters either way is that the wizard's own navigation follows —
|
|
# a step whose Next button is missing is a dead end, whichever number it has.
|
|
chk("...and the wizard's navigation follows it",
|
|
page.eval("getComputedStyle(document.getElementById('sop-next-btn')).display")
|
|
!= "none")
|
|
chk("the page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
|
|
|
page.eval("document.getElementById('loc-paste').value = %s" % json.dumps(GOOD))
|
|
page.eval("document.getElementById('loc-check-btn').click()")
|
|
settle(1.2)
|
|
report = page.eval("(document.getElementById('loc-report')||{}).textContent||''")
|
|
# Four, not five: the header line is recognised and skipped. Asserted at the
|
|
# number rather than "greater than zero", because "read 4 of a 5-line file" is
|
|
# exactly the kind of quiet loss this report exists to make visible.
|
|
chk("checking without importing reads every data row, header skipped",
|
|
"4 rows read" in report, repr(report[:120]))
|
|
chk("...and says what it would create without creating it",
|
|
"9 values would be added" in report, repr(report[:120]))
|
|
chk("...and writes nothing", api(page, "GET", "/api/projects/projA/locations")
|
|
["body"]["nodes"] == [], api(page, "GET", "/api/projects/projA/locations")["body"])
|
|
|
|
page.eval("document.getElementById('loc-import-btn').click()")
|
|
settle(1.6)
|
|
listed = rows_in_list(page)
|
|
chk("importing creates the whole hierarchy, parents included", len(listed) == 9,
|
|
[r["code"] for r in listed])
|
|
chk("...two buildings", sum(1 for r in listed if r["level"] == "building") == 2,
|
|
[r["code"] for r in listed])
|
|
chk("...three floors", sum(1 for r in listed if r["level"] == "floor") == 3,
|
|
[r["code"] for r in listed])
|
|
chk("...four sectors", sum(1 for r in listed if r["level"] == "sector") == 4,
|
|
[r["code"] for r in listed])
|
|
chk("...and a shared parent is created once, not once per row",
|
|
[r["code"] for r in listed].count("PROBE-BUILDING-ONE") == 1,
|
|
[r["code"] for r in listed])
|
|
|
|
print("\n5. the values are codes, and a rename does not move one")
|
|
codes = [r["code"] for r in listed]
|
|
chk("every value carries a slug path, not a display string",
|
|
all(re.fullmatch(r"[A-Z0-9-]+(/[A-Z0-9-]+)*", c) for c in codes), codes)
|
|
chk("...with the hierarchy in the path, which is what CR-018 groups by",
|
|
"PROBE-BUILDING-ONE/PROBE-LEVEL-1/PROBE-SECTOR-A" in codes, codes)
|
|
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
|
target = [n for n in nodes if n["path"] == "PROBE-BUILDING-ONE/PROBE-LEVEL-2"][0]
|
|
renamed = api(page, "PATCH", "/api/projects/projA/locations/" + target["id"],
|
|
{"name": "Renamed Level"})
|
|
chk("renaming a value succeeds", renamed["status"] == 200, renamed)
|
|
chk("...changes the label", renamed["body"]["name"] == "Renamed Level", renamed["body"])
|
|
chk("...and leaves the code EXACTLY as it was",
|
|
renamed["body"]["path"] == target["path"] and renamed["body"]["code"] == target["code"],
|
|
[target["path"], renamed["body"]["path"]])
|
|
kids = [n for n in api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
|
if n["path"].startswith(target["path"] + "/")]
|
|
chk("...and its children's codes with it, so nothing beneath is orphaned",
|
|
kids and all(k["path"].startswith("PROBE-BUILDING-ONE/PROBE-LEVEL-2/") for k in kids),
|
|
[k["path"] for k in kids])
|
|
|
|
print("\n2. duplicates are reported, not merged")
|
|
page.eval("document.getElementById('loc-paste').value = %s" % json.dumps(MESSY))
|
|
page.eval("document.getElementById('loc-import-btn').click()")
|
|
settle(1.6)
|
|
report = page.eval("(document.getElementById('loc-report')||{}).textContent||''")
|
|
chk("the report names duplicates as duplicates", "Duplicates, not merged" in report,
|
|
repr(report[:200]))
|
|
chk("...one already in the project, one repeated inside the file",
|
|
"already in this project" in report and "already on line" in report, repr(report[:300]))
|
|
chk("...and rejected rows separately", "Rejected" in report, repr(report[:200]))
|
|
chk("...naming the reason for each", "no building" in report and "no floor above it" in report,
|
|
repr(report[:400]))
|
|
chk("...and the line number, so it can be found in the file",
|
|
re.search(r"row \d+", report) is not None, repr(report[:200]))
|
|
chk("the report interrupts when it lost rows (T4.5)",
|
|
page.eval("document.getElementById('loc-report').getAttribute('role')") == "alert")
|
|
after = rows_in_list(page)
|
|
chk("a duplicate row created nothing", len(after) == 9 + 3,
|
|
[r["code"] for r in after])
|
|
chk("...while the genuinely new rows in the same file did import",
|
|
"PROBE-BUILDING-THREE/PROBE-LEVEL-1/PROBE-SECTOR-A" in [r["code"] for r in after],
|
|
[r["code"] for r in after])
|
|
|
|
print("\n1b. a CSV file goes through the same parser as a paste")
|
|
page.eval("document.getElementById('loc-paste').value = ''")
|
|
page.eval("""(() => {
|
|
const dt = new DataTransfer();
|
|
dt.items.add(new File([%s], 'probe-locations.csv', {type: 'text/csv'}));
|
|
const el = document.getElementById('loc-file');
|
|
el.files = dt.files;
|
|
el.dispatchEvent(new Event('change', {bubbles: true}));
|
|
return true;
|
|
})()""" % json.dumps("Probe Building Four,Probe Level 1,Probe Sector A\n"))
|
|
for _ in range(20):
|
|
if page.eval("!!document.getElementById('loc-paste').value"):
|
|
break
|
|
time.sleep(0.2)
|
|
chk("choosing a file fills the same box a paste does",
|
|
"Probe Building Four" in page.eval("document.getElementById('loc-paste').value"),
|
|
page.eval("document.getElementById('loc-paste').value")[:80])
|
|
chk("...and says which file it read",
|
|
"probe-locations.csv" in page.eval(
|
|
"(document.getElementById('loc-report')||{}).textContent||''"))
|
|
page.eval("document.getElementById('loc-import-btn').click()")
|
|
settle(1.6)
|
|
chk("...and importing it works the same way",
|
|
"PROBE-BUILDING-FOUR/PROBE-LEVEL-1/PROBE-SECTOR-A"
|
|
in [r["code"] for r in rows_in_list(page)],
|
|
[r["code"] for r in rows_in_list(page)])
|
|
|
|
print("\n3. the list is editable after import")
|
|
page.eval("""(() => {
|
|
document.getElementById('loc-add-parent').value = '';
|
|
const n = document.getElementById('loc-add-name');
|
|
n.value = 'Probe Building Five';
|
|
document.getElementById('loc-add-btn').click();
|
|
return true;
|
|
})()""")
|
|
settle(1.4)
|
|
chk("a value can be added by hand",
|
|
"PROBE-BUILDING-FIVE" in [r["code"] for r in rows_in_list(page)],
|
|
[r["code"] for r in rows_in_list(page)])
|
|
page.eval("""(() => {
|
|
const n = document.getElementById('loc-add-name');
|
|
n.value = ' ';
|
|
document.getElementById('loc-add-btn').click();
|
|
return true;
|
|
})()""")
|
|
settle(0.6)
|
|
chk("...an empty one is refused at the field, not in a dialog",
|
|
bool((page.eval("(document.getElementById('loc-add-err')||{}).textContent||''")).strip()),
|
|
page.eval("(document.getElementById('loc-add-err')||{}).textContent||''"))
|
|
chk("...announced through a live region",
|
|
page.eval("document.getElementById('loc-add-err').getAttribute('role')") == "alert")
|
|
dup = api(page, "POST", "/api/projects/projA/locations",
|
|
{"level": "building", "name": "Probe Building Five"})
|
|
chk("...and adding one that already exists is refused with a reason",
|
|
dup["status"] == 409 and "already" in str(dup["body"].get("detail", "")).lower(), dup)
|
|
orphan = api(page, "POST", "/api/projects/projA/locations",
|
|
{"level": "sector", "name": "Probe Loose Sector"})
|
|
chk("...a sector with nothing above it is refused",
|
|
orphan["status"] == 400 and "floor" in str(orphan["body"].get("detail", "")).lower(),
|
|
orphan)
|
|
|
|
print("\n4. deactivating hides a value without breaking what references it")
|
|
nodes = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
|
floor = [n for n in nodes if n["path"] == "PROBE-BUILDING-ONE/PROBE-LEVEL-1"][0]
|
|
off = api(page, "PATCH", "/api/projects/projA/locations/" + floor["id"], {"active": False})
|
|
chk("a value can be deactivated", off["status"] == 200 and off["body"]["active"] is False, off)
|
|
|
|
live = api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]
|
|
live_paths = [n["path"] for n in live]
|
|
chk("...and disappears from the list new work packages choose from",
|
|
floor["path"] not in live_paths, live_paths)
|
|
chk("...taking its sectors with it, so nothing offers a path to nowhere",
|
|
not [p for p in live_paths if p.startswith(floor["path"] + "/")], live_paths)
|
|
chk("...while its building stays offered", "PROBE-BUILDING-ONE" in live_paths, live_paths)
|
|
|
|
all_nodes = api(page, "GET",
|
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
|
kept = [n for n in all_nodes if n["path"] == floor["path"]]
|
|
chk("...the row itself is RETAINED, not deleted", len(kept) == 1, [n["path"] for n in all_nodes])
|
|
chk("...so a work package already referencing it still resolves its label",
|
|
kept and kept[0]["name"] == floor["name"], kept)
|
|
chk("...and there is no DELETE route at all to lose it by",
|
|
api(page, "DELETE", "/api/projects/projA/locations/" + floor["id"])["status"] == 405,
|
|
api(page, "DELETE", "/api/projects/projA/locations/" + floor["id"]))
|
|
|
|
open_step11(page, base, tok)
|
|
shown = rows_in_list(page)
|
|
chk("the wizard still shows the deactivated value, so it can be brought back",
|
|
any(r["code"] == floor["path"] and r["off"] for r in shown),
|
|
[(r["code"], r["off"]) for r in shown])
|
|
page.eval("""(() => {
|
|
const row = [...document.querySelectorAll('#loc-list .loc-row')]
|
|
.find(r => (r.querySelector('.loc-code')||{}).textContent === %s);
|
|
const box = row.querySelector('.loc-active');
|
|
box.checked = true;
|
|
box.dispatchEvent(new Event('change', {bubbles: true}));
|
|
return true;
|
|
})()""" % json.dumps(floor["path"]))
|
|
settle(1.4)
|
|
back = [n["path"] for n in api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]]
|
|
chk("ticking it puts it back in use", floor["path"] in back, back)
|
|
chk("...on the SAME row, so nothing that referenced it was orphaned",
|
|
[n for n in api(page, "GET",
|
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
|
if n["path"] == floor["path"]][0]["id"] == floor["id"])
|
|
|
|
print("\n re-importing a deactivated value brings it back rather than duplicating it")
|
|
all_now = api(page, "GET",
|
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
|
live_now = [n["path"] for n in
|
|
api(page, "GET", "/api/projects/projA/locations")["body"]["nodes"]]
|
|
sec = [n for n in all_now if n["path"].endswith("/PROBE-SECTOR-B")][0]
|
|
# Deliberate asymmetry, pinned here so nobody "fixes" it into a surprise:
|
|
# deactivating a floor takes its sectors down with it, but ticking the floor
|
|
# back on does NOT resurrect them. A sector may have been switched off for its
|
|
# own reasons, and silently bringing it back would undo a decision nobody made
|
|
# twice. They stay listed, struck through, one tick away.
|
|
chk("reactivating a parent does not silently resurrect its children",
|
|
sec["path"] not in live_now, [sec["path"], live_now])
|
|
chk("...and they are still listed, so they can be brought back deliberately",
|
|
sec["active"] is False)
|
|
api(page, "PATCH", "/api/projects/projA/locations/" + sec["id"], {"active": False})
|
|
again = api(page, "POST", "/api/projects/projA/locations/import",
|
|
{"text": "Probe Building One,Probe Level 1,Probe Sector B"})
|
|
chk("the import reports it as reactivated, not created or duplicate",
|
|
len(again["body"]["reactivated"]) == 1 and not again["body"]["created"], again["body"])
|
|
same = [n for n in api(page, "GET",
|
|
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
|
|
if n["path"] == sec["path"]]
|
|
chk("...and reuses the same row", len(same) == 1 and same[0]["id"] == sec["id"], same)
|
|
|
|
print("\n6. no guessed real-world floor names, and no localStorage behind the list")
|
|
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||[])"))
|
|
src_js = open(os.path.join(ROOT, "html", "work-package-suite-app.js"), encoding="utf-8").read()
|
|
start = src_js.find("// ── STEP 11: LOCATION LIST")
|
|
end = src_js.find("// ── STEP GATES", start) if start >= 0 else -1
|
|
block = src_js[start:end] if start >= 0 and end > start else ""
|
|
chk("the locations code exists as its own bounded block", bool(block), [start, end])
|
|
# Comments stripped first: this block QUOTES CLAUDE.md's rule about localStorage,
|
|
# and a probe that failed on the sentence forbidding the thing rather than on the
|
|
# thing would be the least useful possible false positive.
|
|
code_only = "\n".join(ln for ln in block.splitlines()
|
|
if not ln.strip().startswith(("//", "*", "/*")))
|
|
chk("...and never touches localStorage", "localStorage" not in code_only,
|
|
[ln for ln in code_only.splitlines() if "localStorage" in ln][:3])
|
|
chk("...reaching the server for every read and write",
|
|
block.count("fetch(locApi") >= 4, block.count("fetch(locApi"))
|
|
|
|
# The B100 floor/area list has not been supplied (IMPLEMENTATION.md section 8),
|
|
# so any of these appearing as a location value would be a guess presented as
|
|
# data. Checked over the whole tree, not only the file this task touched.
|
|
GUESSES = ("B100", "1P", "2P", "Fab 7", "Level 2 chase")
|
|
hits = []
|
|
for folder in ("html", "server"):
|
|
for name in sorted(os.listdir(os.path.join(ROOT, folder))):
|
|
if not name.endswith((".js", ".html", ".py", ".css")):
|
|
continue
|
|
text = open(os.path.join(ROOT, folder, name), encoding="utf-8").read()
|
|
# Only where it would be a location VALUE — the review quotes "1P" in
|
|
# prose all over the place, and a comment is not a hardcoded floor.
|
|
for m in re.finditer(r"(building|floor|sector|location)\w*\s*[:=]\s*['\"]([^'\"]{1,40})",
|
|
text, re.I):
|
|
if any(g.lower() in m.group(2).lower() for g in GUESSES):
|
|
hits.append("%s/%s: %s" % (folder, name, m.group(0)[:60]))
|
|
chk("no guessed real-world floor or sector name is assigned anywhere", not hits, hits[:4])
|
|
sample = re.search(r"const LOCATION_SAMPLE = \[(.*?)\]", src_js, re.S)
|
|
chk("the seeded sample values are obviously fake", bool(sample)
|
|
and all("Sample" in ln for ln in re.findall(r"'([^']+)'", sample.group(1))),
|
|
sample.group(1)[:200] if sample else None)
|
|
|
|
print("\n both widths")
|
|
for w, label in ((390, "390px"), (1440, "1440px")):
|
|
page.viewport(w, 900, mobile=(w == 390))
|
|
open_step11(page, base, tok)
|
|
chk("%s: the location step renders" % label,
|
|
page.eval("document.querySelectorAll('#loc-list .loc-row').length") > 0)
|
|
chk("%s: the page does not scroll sideways" % label,
|
|
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
|
|
page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
|
|
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-locations-")
|
|
db_path = os.path.join(tmpdir, "check.db")
|
|
server = None
|
|
try:
|
|
tok = seed(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("\nLocation taxonomy — CR-005\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 — codes not labels, nothing merged, nothing deleted.", "32") + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|