Files
PacketStation/tests/server/test_pm_api.py
T
spencermandClaude Fable 5.1 6b6db0230f
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m6s
ci / image (push) Successful in 38s
ci / e2e (push) Successful in 3m55s
Phase 8: Project Management, find bar, presence and motion
The Project Management screen (/pm, view only) with Overview, Projects & units, Bottlenecks & aging, Hours vs target,
Quality & issues and Find jobs, the period picker, health per unit and per project, drill down, Excel (CSV) per table
and for all, and the PESCO production status report PDF with the controlled-copy stamp; every export logged as
"report exported". The find bar on every home screen: the full search set, the dropdowns, quick flags with Mine, the
flag kinds, Board and List with sorts, filters saved per browser; every filter applied by the server on the list and
the boards, with facets. Presence in packets with role, lock and revoke drop; the motion switch with reduced motion
honoured, card glides, glow and count-up; Back restores the scroll spot; sheet placement and the iPad layout pass. The
data book compared page by page with the old build and six layout differences fixed. Tests for the PM aggregates,
search, filters, presence and exports; Playwright scenarios for the PM screen, the find bar and presence at desktop and
iPad viewports. Docs updated; PLAN.md questions 38 to 42.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 15:08:34 -06:00

373 lines
16 KiB
Python

"""Phase 8: the Project Management read endpoints against the PM world (pmhelpers.py).
The world is built once for the module; every test only reads it.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import pytest
from pmhelpers import (
ATTENTION_HEAD,
BY_PERIOD,
FILED_KEYS,
HEADS,
HEALTH,
HOLD_NOTE,
HOLD_REASON,
KPI_ALERT,
KPI_KEYS,
KPI_TABS,
NOTES,
OPEN_KEYS,
PERIODS,
PIPELINE_COUNTS,
PM_STEPS,
REJECT_NCR,
REJECT_REASON,
STEP_LABEL,
STEP_OF,
TAB_TABLES,
UNIT_HEALTH,
PmWorld,
anon,
by_key,
get_ok,
kpi_labels,
module_shop,
pm_world,
project_rows,
table,
titles,
unit_rows,
)
from pshelpers import needs_pg
pytestmark = needs_pg
ENDPOINTS = ("overview", "projects", "aging", "hours", "quality", "find")
@pytest.fixture(scope="module")
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[PmWorld]:
with module_shop(tmp_path_factory.mktemp("pm_api")) as shop:
yield pm_world(shop)
def pm(w: PmWorld, what: str, sam: str = "pm.paula", **params: Any) -> dict[str, Any]:
return get_ok(w.shop(sam), f"/api/pm/{what}", **params)
def kpis(answer: dict[str, Any]) -> dict[str, dict[str, Any]]:
return by_key(answer["kpis"])
def ids(w: PmWorld, *keys: str) -> set[int]:
return {w.k[k] for k in keys}
# ================================================================ overview
def test_overview_kpis(world: PmWorld) -> None:
out = pm(world, "overview")
assert [k["key"] for k in out["kpis"]] == list(KPI_KEYS)
assert [k["label"] for k in out["kpis"]] == list(kpi_labels("90"))
assert [k["tab"] for k in out["kpis"]] == list(KPI_TABS)
assert {k["key"] for k in out["kpis"] if k["alert"]} == KPI_ALERT
n = {k: v["n"] for k, v in kpis(out).items()}
assert n == {
"open": len(OPEN_KEYS),
"late_units": 1,
"risk_units": 3,
"holds": 1,
"cos": 1,
"shorts": 1,
"rejects": 1,
"over": 1,
"filed": 1,
"lead": "10.0 d",
}
def test_overview_pipeline(world: PmWorld) -> None:
out = pm(world, "overview")
pipe = out["pipeline"]
assert [(p["key"], p["label"]) for p in pipe] == list(PM_STEPS)
assert {p["key"]: p["count"] for p in pipe} == PIPELINE_COUNTS
slow = [p["key"] for p in pipe if p["slowest"]]
assert slow == ["wh_hold"] # held five days; every other step is minutes old
hold = by_key(pipe)["wh_hold"]
assert hold["avg_s"] >= 5 * 86400 - 60 and hold["max_s"] >= hold["avg_s"]
assert hold["oldest"]["pid"] == world.k["k_hold"] and hold["oldest"]["job"]
empty = [p for p in pipe if p["count"] == 0]
assert all(p["oldest"] is None for p in empty)
def test_overview_summary(world: PmWorld) -> None:
out = pm(world, "overview")
lines = out["summary"]
assert lines[0] == f"{len(OPEN_KEYS)} open packets in scope (All projects)."
assert lines[1].startswith("Late: ") and world.unit_number(1) in lines[1]
most = [x for x in lines if x.startswith("Most packets are at ")]
assert most and most[0] in (
f'Most packets are at "{STEP_LABEL["ready"]}" (2).',
f'Most packets are at "{STEP_LABEL["station"]}" (2).',
)
slow = [x for x in lines if x.startswith("Slowest step right now: ")]
assert slow and slow[0].startswith(f'Slowest step right now: "{STEP_LABEL["wh_hold"]}", averaging 5')
assert "Needs attention: 1 warehouse hold, 1 open change order, 1 open short, 1 Q.C. reject." in lines
assert "1 packet is over target hours." in lines
def test_overview_attention(world: PmWorld) -> None:
att = pm(world, "overview")["attention"]
assert att["title"] == "Units late or at risk" and att["head"] == ATTENTION_HEAD
got = {int(r["unit_id"]): r["health"] for r in att["rows"]}
assert got == {world.units[n]: h for n, h in UNIT_HEALTH.items() if h in ("late", "risk")}
for r in att["rows"]:
assert r["cells"][-1] == HEALTH[r["health"]]
late = next(r for r in att["rows"] if r["health"] == "late")
assert late["cells"][0] == world.unit_number(1)
assert late["cells"][3] == "1 d over"
# ================================================================ health and projects
def test_health_per_unit_and_project(world: PmWorld) -> None:
out = pm(world, "projects", expand="all")
t = table(out, "projects")
assert t["title"] == titles()["projects"] and t["head"] == HEADS["projects"]
assert t["note"] == NOTES["projects"]
units = unit_rows(t)
for n, health in UNIT_HEALTH.items():
row = units[world.units[n]]
assert row["health"] == health, (n, row)
assert row["cells"][-1] == HEALTH[health]
assert row["project_id"] == world.projects["L670"]
assert units[world.units[5]]["cells"][12].startswith("100")
assert units[world.units[7]]["cells"][6] == "0"
projects = project_rows(t)
assert projects[world.projects["L670"]]["health"] == "late" # as healthy as its worst unit
assert projects[world.projects["L670"]]["cells"][-1] == "Late"
assert projects[world.projects["L489"]]["health"] == "empty"
assert world.projects["L333"] not in projects # closed, no packet in scope
assert units[world.units[52]]["health"] == "empty"
# the order: each project row, then its units
lvl = [(r["lvl"], r["project_id"]) for r in t["rows"]]
first = lvl.index((0, world.projects["L670"]))
assert all(x == (1, world.projects["L670"]) for x in lvl[first + 1 : first + 8])
def test_drill_down(world: PmWorld) -> None:
pa, pb = world.projects["L670"], world.projects["L489"]
closed = pm(world, "projects")
t = table(closed, "projects")
assert {r["lvl"] for r in t["rows"]} == {0}
assert set(project_rows(t)) == {pa, pb}
one = table(pm(world, "projects", expand=str(pb)), "projects")
assert set(unit_rows(one)) == {world.units[52]}
both = table(pm(world, "projects", expand=f"{pa},{pb}"), "projects")
assert set(unit_rows(both)) == set(world.units.values())
picked = table(pm(world, "projects", project_id=pa), "projects")
assert set(project_rows(picked)) == {pa}
assert set(unit_rows(picked)) == {world.units[n] for n in range(1, 8)}
# any filter other than the project: only units with a packet in scope
at_qc = table(pm(world, "projects", status="qc", expand="all"), "projects")
assert set(unit_rows(at_qc)) == {world.units[2]}
assert pa in project_rows(at_qc)
# ================================================================ aging, hours, quality
def test_aging(world: PmWorld) -> None:
out = pm(world, "aging")
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["aging"])
assert [p["key"] for p in out["pipeline"]] == [k for k, _ in PM_STEPS]
for key in TAB_TABLES["aging"]:
t = table(out, key)
assert t["title"] == titles()[key] and t["head"] == HEADS[key] and t["note"] == NOTES[key]
steps = {r["cells"][0]: r for r in table(out, "aging_steps")["rows"]}
assert steps[STEP_LABEL["wh_hold"]]["cells"][1] == "1"
assert steps[STEP_LABEL["ready"]]["cells"][1] == "2"
opened = table(out, "aging_open")
assert {int(r["pid"]) for r in opened["rows"]} == ids(world, *OPEN_KEYS)
assert opened["rows"][0]["pid"] == world.k["k_hold"] and opened["rows"][0]["flag"] is True
assert opened["rows"][0]["cells"][2] == STEP_LABEL["wh_hold"]
fresh_rows = [r for r in opened["rows"] if r["pid"] in ids(world, "k_ready", "k_asg", "k_qc")]
assert fresh_rows and not any(r["flag"] for r in fresh_rows)
steps_of = {int(r["pid"]): r["cells"][2] for r in opened["rows"]}
for key in OPEN_KEYS:
assert steps_of[world.k[key]] == STEP_LABEL[STEP_OF[key]], key
assert table(out, "aging_history")["rows"], "the moves made by the world"
def test_hours(world: PmWorld) -> None:
out = pm(world, "hours")
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["hours"])
for key in TAB_TABLES["hours"]:
t = table(out, key)
assert t["title"] == titles()[key] and t["head"] == HEADS[key]
assert table(out, "hours_over")["note"] == NOTES["hours_over"]
dept = table(out, "hours_dept")["rows"]
assert len(dept) == 1 and "Weld Parts" in dept[0]["cells"][0]
# counted: the running packet, and every packet past review with target hours
assert dept[0]["cells"][1] == "6"
assert dept[0]["cells"][3] == "3.2" # ticket hours: the running packet's VISUAL rows only
assert dept[0]["cells"][4] == "27.0" # 6 x 4.5
assert dept[0]["cells"][7] == "1"
over = table(out, "hours_over")["rows"]
assert [r["pid"] for r in over] == [world.k["k_run"]]
cells = over[0]["cells"]
# 4 h stored plus the running span (2 h when the world was built, more by now)
timer = float(cells[3])
assert 6.0 <= timer < 8.0, cells
assert cells[4] == "3.2" and cells[5] == "4.5" and cells[6] == f"+{timer - 4.5:.1f} h"
assert "Andy Welder" in cells[1] or "Stall 1" in cells[1]
workers = {r["cells"][0]: r for r in table(out, "hours_worker")["rows"]}
assert set(workers) == {"Andy Welder", "—"}
assert workers["Andy Welder"]["cells"][1] == "5" and workers["—"]["cells"][1] == "1"
assert workers["Andy Welder"]["cells"][7] == "1"
projects = table(out, "hours_project")["rows"]
assert [r["cells"][0] for r in projects] == ["L670 Pump skids"]
variance = [r["cells"][5] for r in table(out, "hours_worker")["rows"]]
assert all(v.startswith(("+", "-", "0")) for v in variance)
def test_quality(world: PmWorld) -> None:
out = pm(world, "quality")
assert [t["key"] for t in out["tables"]] == list(TAB_TABLES["quality"])
for key in TAB_TABLES["quality"]:
t = table(out, key)
assert t["title"] == titles()[key] and t["head"] == HEADS[key]
rej = table(out, "quality_rejects")["rows"]
assert [r["pid"] for r in rej] == [world.k["k_rev"]]
assert rej[0]["cells"][2] == "—" # the reject was made before an inspector name was entered
assert REJECT_REASON in rej[0]["cells"][3] and REJECT_NCR in rej[0]["cells"][3]
shorts = table(out, "quality_shorts")["rows"]
assert [r["pid"] for r in shorts] == [world.k["k_short"]]
assert shorts[0]["flag"] is True and shorts[0]["cells"][5].endswith(" (late)")
assert shorts[0]["cells"][3] == "On order" and shorts[0]["cells"][4] == "PO-77"
assert shorts[0]["cells"][1].startswith("1 ") and shorts[0]["cells"][7] == STEP_LABEL["wh_pulling"]
cos = table(out, "quality_cos")["rows"]
assert [r["pid"] for r in cos] == [world.k["k_co"]]
assert cos[0]["cells"][1].startswith("CO-1") and cos[0]["flag"] is True # open three days
assert cos[0]["cells"][4] == "3.0 d"
holds = table(out, "quality_holds")["rows"]
assert [r["pid"] for r in holds] == [world.k["k_hold"]] and holds[0]["flag"] is True
assert holds[0]["cells"][1:4] == [HOLD_REASON, HOLD_NOTE, "Wade Warehouse"]
assert holds[0]["cells"][4] == "5.0 d"
back = table(out, "quality_sentback")["rows"]
assert [r["pid"] for r in back] == [world.k["k_rev"], world.k["k_draft"]] # newest first
assert back[1]["cells"][3] == "Wes Super" and back[1]["cells"][4] == "Missing drawing"
@pytest.mark.parametrize("period", ["30", "90", "365", "all"])
def test_every_period(world: PmWorld, period: str) -> None:
want = BY_PERIOD[period]
ov = pm(world, "overview", period=period)
sc = ov["scope"]["period"]
assert sc["value"] == period and sc["label"] == PERIODS[period]
assert (sc["start"] is None) == (period == "all")
assert [k["label"] for k in ov["kpis"]] == list(kpi_labels(period))
n = {k: v["n"] for k, v in kpis(ov).items()}
assert n["rejects"] == want["rejects"] and n["filed"] == want["filed"]
if "lead" in want:
assert n["lead"] == want["lead"]
q = pm(world, "quality", period=period)
assert len(table(q, "quality_sentback")["rows"]) == want["sentback"]
assert len(table(q, "quality_rejects")["rows"]) == want["rejects"]
assert table(q, "quality_rejects")["title"] == titles(period)["quality_rejects"]
# the open counts do not follow the period
assert n["open"] == len(OPEN_KEYS) and n["holds"] == 1
# ================================================================ find, scope, access
def test_find_sorted(world: PmWorld) -> None:
every = ids(world, *OPEN_KEYS, *FILED_KEYS)
out = pm(world, "find", sort="project")
got = [c["id"] for c in out["packets"]]
assert set(got) == every and len(got) == len(every)
units = [c["unit"]["number"] for c in out["packets"]]
assert units == sorted(units)
due = pm(world, "find", sort="due")["packets"]
assert due[0]["unit"]["number"] == world.unit_number(1)
late = [c["due_effective"] for c in due]
assert late == sorted(late, key=lambda d: (d is None, d or ""))
upd = [c["updated_at"] for c in pm(world, "find")["packets"]]
assert upd == sorted(upd, reverse=True)
scoped = pm(world, "find", unit_id=world.units[2])["packets"]
assert {c["id"] for c in scoped} == ids(world, "k_rev", "k_qc")
def test_scope_label(world: PmWorld) -> None:
pa = world.projects["L670"]
sc = pm(world, "overview")["scope"]
assert sc["label"] == "All projects" and sc["project"] is None and sc["unit"] is None
assert sc["count"] == len(pm(world, "find")["packets"])
sc = pm(world, "overview", project_id=pa)["scope"]
assert sc["label"] == "Project L670"
assert sc["project"] == {"id": pa, "number": "L670", "name": "Pump skids"}
sc = pm(world, "overview", project_id=pa, unit_id=world.units[1])["scope"]
assert sc["label"].startswith("Unit L670001") and sc["unit"] == {"id": world.units[1], "number": "L670001"}
assert sc["count"] == 2
sc = pm(world, "overview", q="spool")["scope"]
assert sc["label"] == 'All projects · search "spool"'
sc = pm(world, "overview", project_id=pa, dept="03", q="held")["scope"]
parts = sc["label"].split(" · ")
assert parts[0] == "Project L670" and parts[-1] == 'search "held"' and len(parts) == 3
assert sc["count"] == 1
flags = pm(world, "overview", flags="short")["scope"]
assert flags["label"].startswith("All projects · ") and flags["count"] == 1
# mine is ignored on the PM screens
assert pm(world, "overview", flags="mine")["scope"]["count"] == sc_count_all(world)
def sc_count_all(world: PmWorld) -> int:
return int(pm(world, "overview")["scope"]["count"])
def test_scope_follows_the_project(world: PmWorld) -> None:
pb = world.projects["L489"]
out = pm(world, "overview", project_id=pb)
n = {k: v["n"] for k, v in kpis(out).items()}
assert n["open"] == 0 and n["holds"] == 0 and n["late_units"] == 0
assert out["attention"]["rows"] == []
q = pm(world, "quality", project_id=pb)
assert all(table(q, k)["rows"] == [] for k in TAB_TABLES["quality"])
@pytest.mark.parametrize("sam", ["pm.paula", "clerk.clara", "weld.andy"])
def test_every_signed_in_role_reads(world: PmWorld, sam: str) -> None:
for what in ENDPOINTS:
r = world.shop(sam).get(f"/api/pm/{what}")
assert r.status_code == 200, (sam, what, r.text)
def test_unsigned_is_refused_and_nothing_writes(world: PmWorld) -> None:
c = anon(world.shop)
for what in ENDPOINTS:
r = c.get(f"/api/pm/{what}")
assert r.status_code == 401, (what, r.text)
for what in ENDPOINTS:
r = world.shop("pm.paula").post(f"/api/pm/{what}", json={})
assert r.status_code in (404, 405), (what, r.status_code)
# the PM user cannot move a packet
pkt = get_ok(world.shop("pm.paula"), f"/api/packets/{world.k['k_ready']}")
r = world.shop("pm.paula").post(
f"/api/packets/{pkt['id']}/transitions",
json={"action": "assign", "expect": {"status": pkt["status"], "dept": pkt["dept"]["code"]}},
)
assert r.status_code == 403, r.text
def test_bad_period_is_refused(world: PmWorld) -> None:
r = world.shop("pm.paula").get("/api/pm/overview", params={"period": "7"})
assert r.status_code in (400, 422), r.text