Files
PacketStation/tests/server/test_pm_api.py
T

373 lines
16 KiB
Python
Raw Normal View History

"""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