T7.3 - CR-015/A1/D4: the hold clears when the constraints do
ROOT CAUSE, exactly (the done-when asks for it):
Hold state was stored, twice, and derived nowhere.
1) Client: submitHold() wrote prevStatus='Issue', destroying the status the
hold interrupted at the moment it was placed - there was never anything to
return to. Clearing the last constraint then fell into the "Mark it as
Issued now?" confirm, because STATUS_ORDER.indexOf('Issue') is -1 and -1
reads as "before Issued". Decline it and the package stayed on hold with
zero open constraints, forever - the exact state reproduced live in front
of the Micron team.
2) Server: server/app.py's STATUS_ORDER put "Issue" at index 4, so
_released('Issue') was true and every transition OUT of hold skipped
enforce_release_gates() as "already released". POST /api/wps/{id}/status
could walk a held package to Issued past its open constraint. The comment
claimed the ladder was "mirrored in the front end"; the front end's ladder
has no 'Issue' in it at all.
What changed:
- setConstraint() recalculates hold state on EVERY constraint change: clearing
the last open constraint on a held package releases it immediately - no
refresh, no dialog - back to the status recorded on the hold entry (`from`),
which now rides on data.holds and survives save/reload.
- Every hold and release is history: pkgHolds entries carry ts, by, from/to,
reason; the exported Hold Log gained a By column; the server writes
hold_logged / hold_released audit rows (with the reason from data.holds) on
both the upsert and the /status endpoint.
- _released() no longer counts the hold: 'Issue' is a branch, not a rung.
Leaving hold to a field state re-runs the gates; entering hold never did and
still does not. The critical-reopen email keeps its old reach ("has been in
the field" includes on-hold).
- A1 preserved by name and by test: confirmEarlyRelease() still the one place
a gate override is written (comment-stripped grep asserts exactly one
pkgGateOverride assignment), still reason-first, still logged server-side.
D4 - what Urgent does (amended Aug 18): surface the audited path, add no new
one. confirmEarlyRelease() now also covers open constraints, but only for an
Urgent package, and the override must NAME every constraint it crosses - the
server refuses coverage by an old reason. The release banner gives an Urgent
package the override as its primary action (a real <button>); Normal and High
see nothing new and keep the same hard refusal, asserted per priority.
Banner button styled from tokens only; the banner now wraps at narrow widths.
Product question raised, not decided (per CLAUDE.md "asking versus assuming"):
Issue (hold) remains selectable from Draft and Scheduled, as it was before.
The done-when names no state list, so nothing was restricted. If a pre-release
hold is meaningless, closing it off is a one-line follow-up - needs Nick.
Verification (each probe run alone): NEW tests/hold_check.py 50/50, including
the clear-last-constraint regression specifically, the D4 priority matrix
against the server (six 409/200 cases), hold_logged/hold_released audit rows,
and an AST sweep proving every wp.status assignment in server/app.py sits in
a function that runs enforce_release_gates. Regressions: frame_check 39/39,
aggregates_check 16/16.
Items: CR-015, A1, D4 (X2 correction already recorded Aug 18)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
480
tests/hold_check.py
Normal file
480
tests/hold_check.py
Normal file
@@ -0,0 +1,480 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does clearing the last constraint clear the hold? — CR-015 / A1 / D4, T7.3.
|
||||
|
||||
`CR-015` is the highest-severity item in the plan and it was reproduced live in
|
||||
front of the Micron team: a package on hold with every constraint cleared, stuck.
|
||||
The root cause was hold state STORED rather than derived — `submitHold()` wrote
|
||||
`prevStatus='Issue'`, destroying the status the hold interrupted, and the clear
|
||||
path fell into the "Mark it as Issued now?" offer because `'Issue'` is not in
|
||||
`STATUS_ORDER` (`indexOf` gives -1, which reads as "before Issued"). Declining
|
||||
that offer left the package on hold with nothing open, forever.
|
||||
|
||||
1. the clear-last-constraint path, specifically (the regression this file exists for)
|
||||
2. D4: an Urgent package gets the audited override as the primary action; Normal and High are unchanged
|
||||
3. Issue (Hold) branches from every released state
|
||||
4. the server refuses what the browser refuses, and writes the history
|
||||
|
||||
The dialog stubs record every native dialog: the release path must fire NONE.
|
||||
The prompt stub is how the audited override is driven — its return value is the
|
||||
reason, and None is the user backing out.
|
||||
|
||||
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
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
|
||||
from sections_check import set_sop # noqa: E402
|
||||
from stepper_check import dismiss_dialogs # noqa: E402
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def ascii_(v, n=300):
|
||||
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
|
||||
|
||||
|
||||
def settle(seconds=0.8):
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
def wait_creator(page, tries=40):
|
||||
for _ in range(tries):
|
||||
if page.eval("!!window.wpCreatorReady"):
|
||||
return True
|
||||
time.sleep(0.3)
|
||||
return False
|
||||
|
||||
|
||||
def api(base, path, token, method="GET", body=None):
|
||||
"""Returns (http_status, parsed_body). 4xx is a result here, not an error —
|
||||
half of what this file checks is that the server says no."""
|
||||
req = urllib.request.Request(base + path, method=method)
|
||||
req.add_header("Cookie", "wp_session=" + token)
|
||||
req.add_header("Accept", "application/json")
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, data, timeout=15) as r:
|
||||
return r.status, json.loads(r.read().decode() or "null")
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
return e.code, json.loads(e.read().decode() or "null")
|
||||
except Exception:
|
||||
return e.code, None
|
||||
|
||||
|
||||
def audit(base, tok, wp_id, action):
|
||||
_, rows = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=%s" % (wp_id, action), tok)
|
||||
return rows or []
|
||||
|
||||
|
||||
# In-page dialog stubs. Installed once per load; __dlg records what fired so a
|
||||
# path that must be dialog-free can prove it, and __pReturn is the prompt answer.
|
||||
STUBS_JS = """(() => {
|
||||
window.__dlg = [];
|
||||
window.__pReturn = 'Boom lift arrives Friday - releasing to hold schedule';
|
||||
window.prompt = m => { window.__dlg.push(['prompt', String(m)]); return window.__pReturn; };
|
||||
window.alert = m => { window.__dlg.push(['alert', String(m)]); };
|
||||
window.confirm= m => { window.__dlg.push(['confirm',String(m)]); return false; };
|
||||
return true;
|
||||
})()"""
|
||||
|
||||
|
||||
def dlg(page):
|
||||
return json.loads(page.eval("JSON.stringify(window.__dlg||[])"))
|
||||
|
||||
|
||||
def reset_dlg(page):
|
||||
page.eval("window.__dlg=[]")
|
||||
|
||||
|
||||
def constraint_btn(page, index, which):
|
||||
"""Click Open/Cleared/N-A on constraint row `index` — the same buttons a
|
||||
user clicks, re-queried each time because buildConstraints() rebuilds them."""
|
||||
labels = {"open": "Open", "cleared": "Cleared", "na": "N/A"}
|
||||
page.eval("""(() => {
|
||||
const tr = document.querySelectorAll('#constraint-body tr')[%d];
|
||||
[...tr.querySelectorAll('.cstatus button')]
|
||||
.find(b => b.textContent.trim() === %s).click();
|
||||
})()""" % (index, json.dumps(labels[which])))
|
||||
settle(0.4)
|
||||
|
||||
|
||||
def click_status(page, val):
|
||||
page.eval("document.querySelector('#status-group .radio-pill[data-val=%s]').click()"
|
||||
% json.dumps(val))
|
||||
settle(0.4)
|
||||
|
||||
|
||||
def status_of(page):
|
||||
return page.eval("getRadio('status')")
|
||||
|
||||
|
||||
def strip_js_comments(src):
|
||||
"""Good enough for grepping: block comments, then line comments that are not
|
||||
inside a string (approximated by requiring the // not be preceded by : which
|
||||
covers the https:// case that bit BL-017)."""
|
||||
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
|
||||
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
|
||||
|
||||
|
||||
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-hold-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
browser = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
set_sop(db_path, {})
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
root = tok["root"]
|
||||
|
||||
# ── 1. the clear-last-constraint path, driven in the browser ─────────
|
||||
print("\n1. CR-015: the stale hold")
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", root)
|
||||
page.viewport(1440, 900)
|
||||
page.goto(base + "/wp-creation-index.html?project=projA")
|
||||
dismiss_dialogs(page)
|
||||
chk("the creator boots", wait_creator(page))
|
||||
settle(1.5)
|
||||
page.eval(STUBS_JS)
|
||||
page.eval("window.__sentinel = 1")
|
||||
|
||||
n = page.eval("pkgConstraints.length")
|
||||
for i in range(n):
|
||||
constraint_btn(page, i, "cleared")
|
||||
offers = [d for d in dlg(page) if d[0] == "confirm" and "release-ready" in d[1]]
|
||||
chk("the release-ready offer on an unreleased package is unchanged",
|
||||
len(offers) == 1, ascii_(dlg(page)))
|
||||
reset_dlg(page)
|
||||
click_status(page, "In Progress")
|
||||
chk("with everything cleared the package moves to In Progress",
|
||||
status_of(page) == "In Progress", status_of(page))
|
||||
|
||||
constraint_btn(page, 0, "open")
|
||||
chk("flagging a constraint open on an in-progress package opens the hold modal",
|
||||
page.eval("document.getElementById('hold-modal').classList.contains('open')"))
|
||||
page.eval("document.getElementById('hold-details').value='Boom lift recalled for inspection'")
|
||||
page.eval("submitHold()")
|
||||
settle(0.4)
|
||||
chk("submitting the hold puts the package on hold", status_of(page) == "Issue",
|
||||
status_of(page))
|
||||
h = json.loads(page.eval("JSON.stringify(pkgHolds[pkgHolds.length-1]||{})"))
|
||||
chk("the hold entry records timestamp, user, and the status it interrupted",
|
||||
bool(h.get("ts")) and bool(h.get("by")) and h.get("from") == "In Progress",
|
||||
ascii_(h))
|
||||
|
||||
reset_dlg(page)
|
||||
constraint_btn(page, 0, "cleared")
|
||||
chk("clearing the last open constraint clears the hold",
|
||||
status_of(page) != "Issue", status_of(page))
|
||||
chk("...and returns the package to its PRIOR status, not to Issued and not to a guess",
|
||||
status_of(page) == "In Progress", status_of(page))
|
||||
chk("...with no dialog of any kind", dlg(page) == [], ascii_(dlg(page)))
|
||||
chk("...and no refresh — the same document is still running",
|
||||
page.eval("window.__sentinel") == 1)
|
||||
rel = json.loads(page.eval("JSON.stringify(pkgHolds[pkgHolds.length-1]||{})"))
|
||||
chk("the release is in the history with timestamp, user, and what cleared it",
|
||||
rel.get("released") is True and bool(rel.get("ts")) and bool(rel.get("by"))
|
||||
and rel.get("to") == "In Progress", ascii_(rel))
|
||||
|
||||
# Done-when items two and three: the same round trip, again.
|
||||
constraint_btn(page, 1, "open")
|
||||
page.eval("document.getElementById('hold-details').value='Second issue'")
|
||||
page.eval("submitHold()")
|
||||
settle(0.4)
|
||||
chk("logging a NEW constraint places it back on hold", status_of(page) == "Issue")
|
||||
constraint_btn(page, 1, "cleared")
|
||||
chk("clearing that constraint releases it again", status_of(page) == "In Progress",
|
||||
status_of(page))
|
||||
chk("the history now holds two hold/release pairs",
|
||||
page.eval("pkgHolds.filter(h=>h.released).length") == 2
|
||||
and page.eval("pkgHolds.filter(h=>!h.released).length") == 2,
|
||||
ascii_(page.eval("JSON.stringify(pkgHolds.map(h=>!!h.released))")))
|
||||
|
||||
# The return status must survive persistence, not live in a JS variable:
|
||||
# collect the package, put it back mid-hold, and clear.
|
||||
constraint_btn(page, 2, "open")
|
||||
page.eval("document.getElementById('hold-details').value='Third issue'")
|
||||
page.eval("submitHold()")
|
||||
settle(0.4)
|
||||
page.eval("""(() => {
|
||||
const p = collectPackage();
|
||||
loadPackageIntoForm(JSON.parse(JSON.stringify(p)));
|
||||
})()""")
|
||||
settle(0.8)
|
||||
chk("a saved-and-reloaded package is still on hold", status_of(page) == "Issue",
|
||||
status_of(page))
|
||||
constraint_btn(page, 2, "cleared")
|
||||
chk("...and clearing its constraint returns it to the status recorded on the hold",
|
||||
status_of(page) == "In Progress", status_of(page))
|
||||
|
||||
# ── 2. D4: the Urgent override ────────────────────────────────────────
|
||||
print("\n2. D4: Urgent surfaces the audited path; Normal and High do not")
|
||||
page.eval("newPackage()")
|
||||
settle(0.8)
|
||||
page.eval(STUBS_JS)
|
||||
chk("a new package starts with no override recorded",
|
||||
page.eval("pkgGateOverride === null || pkgGateOverride === undefined") is True
|
||||
or page.eval("!pkgGateOverride") is True)
|
||||
|
||||
chk("a Normal package with open constraints shows NO override button",
|
||||
page.eval("!document.querySelector('.rb-act')"))
|
||||
reset_dlg(page)
|
||||
click_status(page, "Issued")
|
||||
alerts = [d for d in dlg(page) if d[0] == "alert"]
|
||||
chk("...and the status control still hard-blocks it with the same refusal",
|
||||
len(alerts) == 1 and "still open" in alerts[0][1], ascii_(dlg(page)))
|
||||
chk("...and the status snapped back", status_of(page) == "Draft", status_of(page))
|
||||
|
||||
page.eval("document.getElementById('wp_priority').value='High'")
|
||||
constraint_btn(page, 0, "open") # any change redraws the banner
|
||||
chk("High is treated exactly like Normal — no override button",
|
||||
page.eval("!document.querySelector('.rb-act')"))
|
||||
|
||||
page.eval("document.getElementById('wp_priority').value='Urgent'")
|
||||
constraint_btn(page, 0, "open")
|
||||
chk("an Urgent package with open constraints offers the override as the "
|
||||
"banner's primary action, a real button",
|
||||
page.eval("(document.querySelector('.rb-act')||{}).tagName") == "BUTTON")
|
||||
|
||||
reset_dlg(page)
|
||||
page.eval("window.__pReturn = null") # back out first
|
||||
page.eval("document.querySelector('.rb-act').click()")
|
||||
settle(0.4)
|
||||
chk("backing out of the prompt releases nothing", status_of(page) == "Draft",
|
||||
status_of(page))
|
||||
chk("...and records no override", page.eval("!pkgGateOverride"))
|
||||
|
||||
reset_dlg(page)
|
||||
page.eval("window.__pReturn = 'Client directive 42 - install proceeds at risk'")
|
||||
page.eval("document.querySelector('.rb-act').click()")
|
||||
settle(0.4)
|
||||
prompts = [d for d in dlg(page) if d[0] == "prompt"]
|
||||
open_names = json.loads(page.eval(
|
||||
"JSON.stringify(pkgConstraints.filter(c=>c.status==='open').map(c=>c.name))"))
|
||||
chk("the prompt names every open constraint it is about to cross",
|
||||
len(prompts) == 1 and all(nm in prompts[0][1] for nm in open_names),
|
||||
ascii_(prompts))
|
||||
chk("taking the override releases the package", status_of(page) == "Issued",
|
||||
status_of(page))
|
||||
ov = json.loads(page.eval("JSON.stringify(pkgGateOverride||{})"))
|
||||
chk("the override records actor, timestamp, reason and the constraints it crossed",
|
||||
bool(ov.get("by")) and bool(ov.get("at"))
|
||||
and ov.get("reason", "").startswith("Client directive")
|
||||
and sorted(ov.get("constraints") or []) == sorted(open_names), ascii_(ov))
|
||||
|
||||
src = strip_js_comments(
|
||||
open(os.path.join(ROOT, "html", "wp-creation-app.js"), encoding="utf-8").read())
|
||||
chk("the client writes a gate override in exactly one place — confirmEarlyRelease()",
|
||||
src.count("pkgGateOverride={") == 1, src.count("pkgGateOverride={"))
|
||||
chk("the hold history has exactly two writers — the hold and the release",
|
||||
src.count("pkgHolds.push(") == 2, src.count("pkgHolds.push("))
|
||||
|
||||
# ── 3. the hold branches from every released state ────────────────────
|
||||
print("\n3. Issue (Hold) is a branch, not a step")
|
||||
page.eval("newPackage()")
|
||||
settle(0.8)
|
||||
page.eval(STUBS_JS)
|
||||
n = page.eval("pkgConstraints.length")
|
||||
for i in range(n):
|
||||
constraint_btn(page, i, "na")
|
||||
reset_dlg(page)
|
||||
for st in ("Issued", "In Progress", "QC"):
|
||||
click_status(page, st)
|
||||
click_status(page, "Issue")
|
||||
opened = page.eval("document.getElementById('hold-modal').classList.contains('open')")
|
||||
page.eval("cancelHold()")
|
||||
settle(0.3)
|
||||
chk("from %s: Issue (hold) opens the log modal, and cancel restores %s" % (st, st),
|
||||
opened and status_of(page) == st, status_of(page))
|
||||
|
||||
page.close()
|
||||
browser.close()
|
||||
browser = None
|
||||
|
||||
# ── 4. the server refuses what the browser refuses, and writes history ─
|
||||
print("\n4. the server: gates and history")
|
||||
CON = lambda st: [{"name": "Boom lift", "status": st, "comment": ""},
|
||||
{"name": "Permits", "status": "cleared", "comment": ""}]
|
||||
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpN1", "project_id": "projA", "number": "N-1", "subject": "normal",
|
||||
"status": "Issued", "data": {"priority": "Normal", "constraints": CON("open")}})
|
||||
chk("Normal + open constraint: refused", code == 409, code)
|
||||
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpU1", "project_id": "projA", "number": "U-1", "subject": "urgent bare",
|
||||
"status": "Issued", "data": {"priority": "Urgent", "constraints": CON("open")}})
|
||||
chk("Urgent with NO override: refused — urgency alone is not a reason", code == 409, code)
|
||||
|
||||
ov = {"reason": "Client directive 42", "at": "2026-08-19T00:00:00Z", "by": "Root"}
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpU2", "project_id": "projA", "number": "U-2", "subject": "urgent unnamed",
|
||||
"status": "Issued",
|
||||
"data": {"priority": "Urgent", "constraints": CON("open"), "gateOverride": ov}})
|
||||
chk("Urgent + override that names nothing: refused — it must say what it covers",
|
||||
code == 409, code)
|
||||
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpU3", "project_id": "projA", "number": "U-3", "subject": "urgent wrong name",
|
||||
"status": "Issued",
|
||||
"data": {"priority": "Urgent", "constraints": CON("open"),
|
||||
"gateOverride": dict(ov, constraints=["Permits"])}})
|
||||
chk("Urgent + override naming a DIFFERENT constraint: refused — no riding through "
|
||||
"on an old reason", code == 409, code)
|
||||
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpN2", "project_id": "projA", "number": "N-2", "subject": "normal named",
|
||||
"status": "Issued",
|
||||
"data": {"priority": "Normal", "constraints": CON("open"),
|
||||
"gateOverride": dict(ov, constraints=["Boom lift"])}})
|
||||
chk("Normal + a fully-named override: still refused — the path is Urgent-only",
|
||||
code == 409, code)
|
||||
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpU4", "project_id": "projA", "number": "U-4", "subject": "urgent covered",
|
||||
"status": "Issued",
|
||||
"data": {"priority": "Urgent", "constraints": CON("open"),
|
||||
"gateOverride": dict(ov, constraints=["Boom lift"])}})
|
||||
chk("Urgent + an override naming the open constraint: released", code == 200, code)
|
||||
rows = audit(base, root, "wpU4", "gate_overridden")
|
||||
chk("...and the audit log names the constraints it crossed",
|
||||
rows and "Boom lift" in ((rows[0].get("detail") or {}).get("constraints") or []),
|
||||
ascii_(rows[:1]))
|
||||
|
||||
# A1: the predecessor gate is untouched — refusable only with a reason.
|
||||
api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpPred", "project_id": "projA", "number": "P-0", "subject": "upstream",
|
||||
"status": "In Progress", "data": {"constraints": CON("cleared")}})
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpA1c", "project_id": "projA", "number": "A1-1", "subject": "downstream",
|
||||
"status": "Issued",
|
||||
"data": {"constraints": CON("cleared"), "predecessors": ["wpPred"]}})
|
||||
chk("A1: an unclosed predecessor still refuses a release", code == 409, code)
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpA1c", "project_id": "projA", "number": "A1-1", "subject": "downstream",
|
||||
"status": "Issued",
|
||||
"data": {"constraints": CON("cleared"), "predecessors": ["wpPred"],
|
||||
"gateOverride": {"reason": "Staged ahead of turnover", "by": "Root",
|
||||
"at": "2026-08-19T00:00:00Z"}}})
|
||||
chk("A1: the reasoned override still releases", code == 200, code)
|
||||
chk("A1: and still logs", bool(audit(base, root, "wpA1c", "gate_overridden")))
|
||||
|
||||
# The hold history, through the upsert (how the browser and outbox save).
|
||||
api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpH1", "project_id": "projA", "number": "H-1", "subject": "hold history",
|
||||
"status": "In Progress", "data": {"constraints": CON("cleared")}})
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpH1", "project_id": "projA", "number": "H-1", "subject": "hold history",
|
||||
"status": "Issue",
|
||||
"data": {"constraints": CON("open"),
|
||||
"holds": [{"ts": "2026-08-19T01:00:00Z", "constraint": "Boom lift",
|
||||
"details": "Lift recalled for inspection", "by": "Root",
|
||||
"from": "In Progress"}]}})
|
||||
rows = audit(base, root, "wpH1", "hold_logged")
|
||||
chk("going on hold writes hold_logged with the reason and the interrupted status",
|
||||
code == 200 and rows
|
||||
and (rows[0].get("detail") or {}).get("reason") == "Lift recalled for inspection"
|
||||
and (rows[0].get("detail") or {}).get("from") == "In Progress",
|
||||
ascii_((code, rows[:1])))
|
||||
code, _ = api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpH1", "project_id": "projA", "number": "H-1", "subject": "hold history",
|
||||
"status": "In Progress",
|
||||
"data": {"constraints": CON("cleared"),
|
||||
"holds": [{"ts": "2026-08-19T01:00:00Z", "constraint": "Boom lift",
|
||||
"details": "Lift recalled for inspection", "by": "Root",
|
||||
"from": "In Progress"},
|
||||
{"ts": "2026-08-19T02:00:00Z", "released": True,
|
||||
"constraint": "Boom lift", "by": "Root",
|
||||
"details": "Hold released - last open constraint cleared",
|
||||
"to": "In Progress"}]}})
|
||||
rows = audit(base, root, "wpH1", "hold_released")
|
||||
chk("coming off hold writes hold_released with where it went back to",
|
||||
code == 200 and rows
|
||||
and (rows[0].get("detail") or {}).get("to") == "In Progress",
|
||||
ascii_((code, rows[:1])))
|
||||
|
||||
# The /status endpoint is not a side door: same gates, same history.
|
||||
api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpS1", "project_id": "projA", "number": "S-1", "subject": "endpoint",
|
||||
"status": "In Progress", "data": {"constraints": CON("cleared")}})
|
||||
code, _ = api(base, "/api/wps/wpS1/status", root, "POST", {"status": "Issue"})
|
||||
chk("/status can place a package on hold", code == 200, code)
|
||||
chk("...and writes hold_logged", bool(audit(base, root, "wpS1", "hold_logged")))
|
||||
api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpS1", "project_id": "projA", "number": "S-1", "subject": "endpoint",
|
||||
"status": "Issue", "data": {"constraints": CON("open")}})
|
||||
code, _ = api(base, "/api/wps/wpS1/status", root, "POST", {"status": "Issued"})
|
||||
chk("/status cannot walk a held package past its open constraint", code == 409, code)
|
||||
api(base, "/api/wps", root, "POST", {
|
||||
"id": "wpS1", "project_id": "projA", "number": "S-1", "subject": "endpoint",
|
||||
"status": "Issue", "data": {"constraints": CON("cleared")}})
|
||||
code, _ = api(base, "/api/wps/wpS1/status", root, "POST", {"status": "In Progress"})
|
||||
chk("/status releases once the record is clear", code == 200, code)
|
||||
chk("...and writes hold_released", bool(audit(base, root, "wpS1", "hold_released")))
|
||||
|
||||
# Grep half of D4's "no unwritten release": every status assignment in
|
||||
# app.py sits in a function that runs the gates. (Constructor-built rows
|
||||
# in seed scripts are out of scope — S13 owns seeding.)
|
||||
tree = ast.parse(open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read())
|
||||
bad = []
|
||||
for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
|
||||
assigns = [n for n in ast.walk(fn) if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Attribute) and t.attr == "status"
|
||||
for t in n.targets)]
|
||||
if not assigns:
|
||||
continue
|
||||
calls = {c.func.id for c in ast.walk(fn)
|
||||
if isinstance(c, ast.Call) and isinstance(c.func, ast.Name)}
|
||||
if "enforce_release_gates" not in calls:
|
||||
bad.append(fn.name)
|
||||
chk("every status assignment in server/app.py is inside a function that runs "
|
||||
"the release gates", not bad, ascii_(bad))
|
||||
|
||||
finally:
|
||||
if browser is not None:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
if server is not None:
|
||||
try:
|
||||
server.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("\n" + "-" * 54)
|
||||
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1 if _FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user