Phase 8: Project Management, find bar, presence and motion
ci / frontend (push) Successful in 43s
ci / server (push) Successful in 11m6s
ci / image (push) Successful in 38s
ci / e2e (push) Successful in 3m55s

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]>
This commit is contained in:
2026-10-18 15:08:34 -06:00
co-authored by Claude Fable 5.1
parent 904437153d
commit 6b6db0230f
90 changed files with 10450 additions and 563 deletions
+623
View File
@@ -0,0 +1,623 @@
"""Helpers for the phase 8 tests: one database per test module, the Project Management world, and the texts of the
phase 8 contract (periods, PM steps, health, KPI labels, table titles and heads).
Built on shophelpers, qchelpers and closehelpers (imported, never edited). Nothing here reads VISUAL or M-Files.
The labor hours are rows written straight into `visual_labor`, as the feed tests do.
The PM world (dates are calendar days in the site time zone, America/Denver):
project L670 "Pump skids" (Acme Gas, PO-9, due today + 20), alias OLD670
unit 001 "Skid A" due yesterday -> late k_run (in progress, timer 6 h, ticket 3.2 h, target 4.5 h),
k_short (warehouse pulling, an open short with a past ETA)
unit 002 "Skid B" due today + 4 -> risk k_rev (review after a Q.C. reject), k_qc (at Q.C.)
unit 003 "Skid C" due today + 30 -> risk k_hold (warehouse hold for 5 days), k_inc (warehouse waiting)
unit 004 "Skid D" due today + 30 -> risk k_co (ready, change order CO-1 open 3 days), k_pulled
unit 005 "Skid E" due today + 30 -> done k_filed (filed, created 10 days ago), k_oldfiled (filed 120
days ago, created 140 days ago, a synthetic history)
unit 006 "Skid F" no due (project's) -> ok k_draft, k_ready, k_asg (assigned), k_complete
unit 007 "Skid G" due today + 30 -> empty
project L489 "BPX" (due today + 9), unit 052 with no packets -> empty
project L333 closed, unit 001 with no packets: not in scope
History rows inserted with old timestamps (append-only tables, so inserted, never updated):
k_draft: return_to_clerical 400 and 45 days ago (sent back)
k_oldfiled: send_to_warehouse -139 d, wh_send_back -138 d (sent back), send_to_warehouse -137 d, wh_release -130 d,
send_to_qc -126 d, qc_reject -125 d (sent back; a rejected qc_history row), send_to_qc -124 d,
qc_accept -123 d (an accepted qc_history row), file -120 d.
So: open 12, late units 1, units at risk 3, holds 1, change orders 1, shorts 1, over target 1; by period
rejects 30: 1, 90: 1, 365: 2, all: 2; filed 30: 1, 90: 1, 365: 2, all: 2;
sent back 30: 1, 90: 2, 365: 4, all: 5; lead time 90: 10.0 d, 365: 15.0 d.
"""
from __future__ import annotations
import secrets
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import pytest
from fakedir import FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import step, tiny_pdf, upload
from pshelpers import ORIGIN_HEADERS, PG_URL, app_conn, fake_visual, make_settings
from shophelpers import (
SIGN,
Shop,
draft,
issue_co,
ok,
open_shop,
pull_all,
ready_skipped,
run,
set_running_since,
start,
to_station,
to_warehouse,
)
from packetstation.settings import Settings
SITE_TZ = "America/Denver"
# ---------------------------------------------------------------- the contract's texts
PERIODS = {"30": "Last 30 days", "90": "Last 90 days", "365": "Last 12 months", "all": "All time"}
PM_STEPS = (
("draft", "Ready to release (Clerical)"),
("wh_incoming", "Warehouse: waiting"),
("wh_pulling", "Warehouse: being pulled"),
("wh_hold", "Warehouse: on hold"),
("wh_pulled", "Warehouse: pulled, ready for production"),
("ready", "Ready to assign"),
("station", "At a station"),
("review", "Supervisor review"),
("qc", "At Q.C."),
("complete", "Back for data book"),
)
STEP_LABEL = dict(PM_STEPS)
HEALTH = {"ok": "On track", "risk": "At risk", "late": "Late", "done": "Complete", "empty": "No packets yet"}
KPI_KEYS = ("open", "late_units", "risk_units", "holds", "cos", "shorts", "rejects", "over", "filed", "lead")
KPI_TABS = ("aging", "projects", "projects", "quality", "quality", "quality", "quality", "hours", "projects", "aging")
KPI_ALERT = {"late_units", "risk_units", "holds", "cos", "shorts", "rejects", "over"}
def kpi_labels(period: str = "90") -> tuple[str, ...]:
lower = PERIODS[period].lower()
return (
"Open packets",
"Late units",
"Units at risk",
"Warehouse holds",
"Open change orders",
"Open shorts",
f"Q.C. rejects · {lower}",
"Packets over target hours",
f"Filed · {lower}",
"Average lead time, created → filed",
)
HOURS_TAIL = ["Packets", "Timer h", "Ticket h", "Target h", "Variance h", "Timer / target", "Over target"]
HEADS: dict[str, list[str]] = {
"projects": [
"Project / unit",
"Name",
"Customer",
"Due",
"Left",
"Units",
"Packets",
"Draft",
"Warehouse",
"Shop",
"Q.C.",
"Done",
"% done",
"Health",
],
"aging_steps": ["Step", "Packets", "Average", "Longest", "Oldest packet"],
"aging_open": ["Job", "Unit", "Step", "With", "In this step", "Total age", "Unit due", "Flags"],
"aging_history": ["Step", "Moves", "Average", "Longest"],
"hours_dept": ["Department", *HOURS_TAIL],
"hours_worker": ["Worker", *HOURS_TAIL],
"hours_project": ["Project", *HOURS_TAIL],
"hours_over": ["Job", "Station / worker", "Status", "Timer h", "Ticket h", "Target h", "Over by"],
"quality_rejects": ["Job", "Date", "Inspector", "Reason / NCR", "Station / worker"],
"quality_shorts": ["Job", "Item", "Short qty", "Reason", "PO / req", "ETA", "Age", "Packet is"],
"quality_cos": ["Job", "Change order", "Change", "Waiting on", "Open for"],
"quality_holds": ["Job", "Reason", "Note", "Put on hold by", "On hold for"],
"quality_sentback": ["Job", "When", "Action", "By", "Reason"],
"jobs": [
"Job",
"Project",
"Unit",
"Operation",
"Title",
"Department",
"Step",
"With",
"Station",
"Worker",
"Drawing",
"Rev",
"Unit due",
"Days in step",
"Timer h",
"Ticket h",
"Target h",
"Created",
"Updated",
"Change orders open",
"Shorts open",
],
}
ATTENTION_HEAD = ["Unit", "Description", "Due", "Left", "Packets", "% done", "Health"]
def titles(period: str = "90") -> dict[str, str]:
p = PERIODS[period]
return {
"projects": "Projects and units",
"aging_steps": "Time in the current step (open packets)",
"aging_open": "Every open packet, longest waiting first",
"aging_history": f"Average time per step, moves completed ({p})",
"hours_dept": "By department",
"hours_worker": "By welder / worker",
"hours_project": "By project",
"hours_over": "Packets over target",
"quality_rejects": f"Q.C. rejects ({p})",
"quality_shorts": "Open shorts",
"quality_cos": "Open change orders",
"quality_holds": "Warehouse holds",
"quality_sentback": f"Sent back ({p})",
"jobs": "All jobs in scope",
}
NOTES = {
"projects": "Tap a project to show its units. Done = accepted by Q.C. or filed. At risk = due within 7 days and"
" under 75% done, or a hold or open change order.",
"aging_steps": "How long packets have been sitting where they are right now.",
"aging_open": "Over 3 days in one step is flagged.",
"aging_history": "From the step history recorded on every move.",
"hours_over": "Station timer hours and VISUAL ticket hours against the work-order target hours. The timer is the"
" clock of record.",
}
TAB_TABLES = {
"aging": ("aging_steps", "aging_open", "aging_history"),
"hours": ("hours_dept", "hours_worker", "hours_project", "hours_over"),
"quality": ("quality_rejects", "quality_shorts", "quality_cos", "quality_holds", "quality_sentback"),
}
REJECT_REASON = "Undercut on weld 1, regrind and reweld"
REJECT_NCR = "NCR-2026-0031"
HOLD_REASON = "Waiting on purchasing / PO"
HOLD_NOTE = "PO 4471"
FORMULA_TITLE = "=HYPERLINK(1)"
# ---------------------------------------------------------------- one database per module
def today_site() -> date:
return datetime.now(ZoneInfo(SITE_TZ)).date()
@contextmanager
def module_settings(tmp: Path) -> Iterator[Settings]:
"""The conftest `pg_settings` for a whole module: a fresh database with ps_migrate and ps_app, migrated to head,
dropped afterwards. Lets a module build its world once."""
import psycopg
from psycopg import sql
from psycopg.conninfo import conninfo_to_dict
from packetstation import db, migrate
assert PG_URL
info = conninfo_to_dict(PG_URL)
mig_pw, app_pw = secrets.token_urlsafe(18), secrets.token_urlsafe(18)
(tmp / "mig").write_text(mig_pw + "\n")
(tmp / "app").write_text(app_pw + "\n")
name = f"ps_test_{secrets.token_hex(4)}"
s = make_settings(
tmp,
pg_host=info.get("host", "localhost"),
pg_port=int(info.get("port", 5432)),
pg_database=name,
pg_migrate_password_file=tmp / "mig",
pg_app_password_file=tmp / "app",
pg_superuser_url=PG_URL,
# A world takes minutes to build over a slow tunnel: no idle lock or idle sign-out while it does.
session_lock_minutes=600,
session_idle_minutes=720,
)
migrate.bootstrap_roles(s)
with psycopg.connect(PG_URL, autocommit=True) as su:
for user, pw in (("ps_migrate", mig_pw), ("ps_app", app_pw)):
su.execute(sql.SQL("ALTER ROLE {} LOGIN PASSWORD {}").format(sql.Identifier(user), sql.Literal(pw)))
with db.connect_as("migrate", s, autocommit=True) as conn:
migrate.run(conn, db.MIGRATIONS_DIR)
try:
yield s
finally:
db.close_pool()
with psycopg.connect(PG_URL, autocommit=True) as su:
su.execute(sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(sql.Identifier(name)))
@contextmanager
def module_shop(tmp: Path, fake_dir: FakeDirectory | None = None) -> Iterator[Shop]:
"""The app on a module database with the fake directory and DEV VISUAL settings; every test user signed in."""
from packetstation import guard
with pytest.MonkeyPatch.context() as mp:
mp.setattr(guard, "load_visual_settings", lambda: fake_visual(False))
with module_settings(tmp) as s:
gen = open_shop(s, fake_dir or FakeDirectory())
shop = next(gen)
try:
yield shop
finally:
gen.close()
def anon(shop: Shop) -> TestClient:
"""A client with no session."""
return TestClient(shop.main.app, headers=ORIGIN_HEADERS)
def get_ok(c: TestClient, url: str, **params: Any) -> dict[str, Any]:
r = c.get(url, params={k: v for k, v in params.items() if v is not None})
assert r.status_code == 200, (url, params, r.text)
out: dict[str, Any] = r.json()
return out
# ---------------------------------------------------------------- SQL setup
def make_project(
settings: Settings,
number: str,
name: str,
*,
customer: str | None = None,
po: str | None = None,
due: date | None = None,
status: str = "open",
) -> int:
with app_conn(settings) as conn:
row = conn.execute(
"INSERT INTO projects (number, name, customer, po, due, status)"
" VALUES (%s, %s, %s, %s, %s, %s::project_status) RETURNING id",
(number, name, customer, po, due, status),
).fetchone()
return int(row[0])
def make_unit(settings: Settings, project_id: int, lot: str, name: str | None = None, due: date | None = None) -> int:
with app_conn(settings) as conn:
row = conn.execute(
"INSERT INTO units (project_id, lot, name, due) VALUES (%s, %s, %s, %s) RETURNING id",
(project_id, lot, name, due),
).fetchone()
return int(row[0])
def add_alias(settings: Settings, project_id: int, alias: str) -> None:
run(
settings,
"INSERT INTO project_aliases (alias, project_id, kind) VALUES (%s, %s, 'import')",
(alias.upper(), project_id),
)
def transition_at(
settings: Settings,
packet_id: int,
days_ago: float,
action: str,
from_status: str | None,
to_status: str | None,
*,
by: str = "clerk.clara",
reason: str | None = None,
dept: str = "03",
) -> None:
"""One packet_transitions row with an old timestamp (the table is append-only: insert, never update)."""
run(
settings,
"INSERT INTO packet_transitions (packet_id, at, by, action, from_status, to_status, from_dept, to_dept,"
" reason) VALUES (%s, now() - make_interval(secs => %s), (SELECT id FROM users WHERE sam_account = %s),"
" %s, %s::packet_status, %s::packet_status, %s, %s, %s)",
(packet_id, days_ago * 86400, by, action, from_status, to_status, dept, dept, reason),
)
def qc_history_at(settings: Settings, packet_id: int, days_ago: float, result: str, note: str | None = None) -> None:
run(
settings,
"INSERT INTO qc_history (packet_id, at, result, inspector, note, dept_code, snapshot)"
" VALUES (%s, now() - make_interval(secs => %s), %s, 'Quinn QC', %s, '03', '{}')",
(packet_id, days_ago * 86400, result, note),
)
def labor_rows(settings: Settings, packet_id: int, rows: list[tuple[str, int, str]]) -> None:
"""Make a packet a VISUAL packet and give its work orders VISUAL ticket hours: rows of (wo_base, seq, hours).
Each work order gets the seq of its row; visual_labor gets one row per entry, as the feed writes them."""
with app_conn(settings) as conn:
conn.execute("UPDATE packets SET source_kind = 'visual' WHERE id = %s", (packet_id,))
for base, seq, hours in rows:
conn.execute(
"UPDATE packet_work_orders SET seq = %s WHERE packet_id = %s AND wo_base = %s", (seq, packet_id, base)
)
conn.execute(
"INSERT INTO visual_labor (wo_base, lot, split, sub, seq, hours, tickets, last_ticket)"
" SELECT wo_base, lot, split, sub, seq, %s, 2, current_date FROM packet_work_orders"
" WHERE packet_id = %s AND wo_base = %s"
" ON CONFLICT (wo_base, lot, split, sub, seq) DO UPDATE SET hours = EXCLUDED.hours",
(hours, packet_id, base),
)
def quick_draft(
shop: Shop, unit_id: int, title: str, *, n: int, send: str | None = None, **over: Any
) -> dict[str, Any]:
"""A hand draft by clerk.clara with neutral values (drawing D-61nn, work order Wnn XY, no BOM) unless
overridden. `send` "warehouse" sends it to the warehouse; a department code releases it there (ready). Either
attaches a one-page drawing and answers the heat map first. A bare draft costs one request."""
body: dict[str, Any] = {
"unit_id": unit_id,
"dept_code": "03",
"op": "WELD-PART",
"title": title,
"customer": "Plains Midstream",
"drawing": f"D-61{n:02d}",
"rev": "A",
"wps": "WPS-11",
"work_orders": [
{"wo_base": f"W{n:02d}XY", "lot": "001", "split": "0", "sub": "0", "qty": 1, "target_hrs": 1},
],
"bom": [],
}
body.update(over)
c = shop("clerk.clara")
r = c.post("/api/packets", json=body)
assert r.status_code == 201, r.text
pkt: dict[str, Any] = r.json()
if send:
assert c.patch(f"/api/packets/{pkt['id']}", json={"set": {"heat_map": True}, "base": {}}).status_code == 200
a = upload(c, tiny_pdf(1))
assert a.status_code == 201, a.text
pkt = ok(
c.post(
f"/api/packets/{pkt['id']}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN}
)
)
if send == "warehouse":
pkt = ok(step(c, pkt, "send_to_warehouse"))
assert pkt["status"] == "warehouse", pkt["status"]
else:
pkt = ok(step(c, pkt, "release_to_shop", dept=send))
assert pkt["status"] == "ready", pkt["status"]
return pkt
# ---------------------------------------------------------------- the PM world
@dataclass
class PmWorld:
shop: Shop
today: date
projects: dict[str, int] = field(default_factory=dict) # "L670", "L489", "L333"
units: dict[int, int] = field(default_factory=dict) # 1..7 -> L670 units; 52 -> L489 052
k: dict[str, int] = field(default_factory=dict) # packet ids by key
@property
def settings(self) -> Settings:
return self.shop.settings
def unit_number(self, n: int) -> str:
return f"L670{n:03d}"
OPEN_KEYS = (
"k_run",
"k_short",
"k_rev",
"k_qc",
"k_hold",
"k_inc",
"k_co",
"k_pulled",
"k_draft",
"k_ready",
"k_asg",
"k_complete",
)
FILED_KEYS = ("k_filed", "k_oldfiled")
# The PM step of each open packet, and the expected pipeline counts.
STEP_OF = {
"k_draft": "draft",
"k_inc": "wh_incoming",
"k_short": "wh_pulling",
"k_hold": "wh_hold",
"k_pulled": "wh_pulled",
"k_ready": "ready",
"k_co": "ready",
"k_asg": "station",
"k_run": "station",
"k_rev": "review",
"k_qc": "qc",
"k_complete": "complete",
}
PIPELINE_COUNTS = {key: sum(1 for v in STEP_OF.values() if v == key) for key, _ in PM_STEPS}
UNIT_HEALTH = {1: "late", 2: "risk", 3: "risk", 4: "risk", 5: "done", 6: "ok", 7: "empty"}
# By period: Q.C. rejects, filed, sent back, lead time.
BY_PERIOD = {
"30": {"rejects": 1, "filed": 1, "sentback": 1},
"90": {"rejects": 1, "filed": 1, "sentback": 2, "lead": "10.0 d"},
"365": {"rejects": 2, "filed": 2, "sentback": 4, "lead": "15.0 d"},
"all": {"rejects": 2, "filed": 2, "sentback": 5},
}
def pm_world(shop: Shop) -> PmWorld:
"""Build the PM world described at the top of this file. Takes a few minutes over a slow tunnel."""
from closehelpers import at_complete, filed
from qchelpers import at_qc, reject
s = shop.settings
today = today_site()
w = PmWorld(shop, today)
pa = make_project(s, "L670", "Pump skids", customer="Acme Gas", po="PO-9", due=today + timedelta(days=20))
add_alias(s, pa, "OLD670")
w.projects["L670"] = pa
dues = {
1: today - timedelta(days=1),
2: today + timedelta(days=4),
3: today + timedelta(days=30),
4: today + timedelta(days=30),
5: today + timedelta(days=30),
6: None,
7: today + timedelta(days=30),
}
for n, due in dues.items():
w.units[n] = make_unit(s, pa, f"{n:03d}", f"Skid {'ABCDEFG'[n - 1]}", due)
pb = make_project(s, "L489", "BPX", customer="BPX", due=today + timedelta(days=9))
w.projects["L489"] = pb
w.units[52] = make_unit(s, pb, "052", "48in separator")
pc = make_project(s, "L333", "Old closed job", status="closed")
w.projects["L333"] = pc
make_unit(s, pc, "001", "Closed unit")
u = w.units
wade = shop("wh.wade")
# unit 001, late: a running packet over target with ticket hours, a short with a past ETA
run_pkt = start(shop, to_station(shop, ready_skipped(shop, u[1], title="Running spool")))
w.k["k_run"] = run_pkt["id"]
run(
s,
"UPDATE packet_stages SET clock_elapsed_ms = %s WHERE packet_id = %s AND closed_at IS NULL",
(4 * 3600 * 1000, run_pkt["id"]),
)
set_running_since(s, run_pkt["id"], 2 * 3600)
labor_rows(s, run_pkt["id"], [("L670001", 10, "1.5"), ("L670002", 20, "1.7")])
short = to_warehouse(shop, draft(shop, u[1], title="Short spool"))
yesterday = (today - timedelta(days=1)).isoformat()
short = ok(
wade.post(
f"/api/packets/{short['id']}/bom/1/short",
json={"qty": 1, "reason": "On order", "ref": "PO-77", "eta": yesterday, "expect_open": False},
)
)
w.k["k_short"] = short["id"]
# unit 002, at risk by the due rule: a packet back in review after a Q.C. reject, a packet at Q.C.
rev = at_qc(shop, u[2], title="Rejected spool")
rev = ok(reject(shop, rev, REJECT_REASON, ncr=REJECT_NCR))
assert rev["status"] == "review", rev["status"]
w.k["k_rev"] = rev["id"]
w.k["k_qc"] = at_qc(shop, u[2], title="Inspected spool")["id"]
# unit 003, at risk by a hold: a warehouse hold five days old, a packet waiting
hold = to_warehouse(shop, draft(shop, u[3], title="Held spool"))
ok(
wade.post(
f"/api/packets/{hold['id']}/wh",
json={"stage": "hold", "reason": HOLD_REASON, "note": HOLD_NOTE, "expect_stage": "incoming"},
)
)
run(
s,
"UPDATE packet_wh SET at = now() - interval '5 days' WHERE packet_id = %s AND closed_at IS NULL",
(hold["id"],),
)
w.k["k_hold"] = hold["id"]
w.k["k_inc"] = to_warehouse(shop, draft(shop, u[3], title="Waiting spool"))["id"]
# unit 004, at risk by a change order: CO-1 open three days, a packet pulled
co = issue_co(shop, ready_skipped(shop, u[4], title="Changed spool"), ["03"])
run(s, "UPDATE change_orders SET issued_at = now() - interval '3 days' WHERE packet_id = %s", (co["id"],))
w.k["k_co"] = co["id"]
pulled = pull_all(shop, to_warehouse(shop, draft(shop, u[4], title="Pulled spool")))
ok(wade.post(f"/api/packets/{pulled['id']}/wh", json={"stage": "pulled", "expect_stage": "pulling"}))
w.k["k_pulled"] = pulled["id"]
# unit 005, done: one filed now (created ten days ago), one filed 120 days ago with a synthetic history
fp = filed(shop, u[5])
run(s, "UPDATE packets SET created_at = now() - interval '10 days' WHERE id = %s", (fp["id"],))
w.k["k_filed"] = fp["id"]
old = draft(shop, u[5], title="Old filed spool")
oid = old["id"]
run(
s,
"UPDATE packets SET status = 'filed', status_at = now() - interval '120 days',"
" created_at = now() - interval '140 days' WHERE id = %s",
(oid,),
)
for days, action, frm, to, reason in (
(139, "send_to_warehouse", "draft", "warehouse", None),
(138, "wh_send_back", "warehouse", "draft", "Wrong BOM"),
(137, "send_to_warehouse", "draft", "warehouse", None),
(130, "wh_release", "warehouse", "ready", None),
(126, "send_to_qc", "review", "qc", None),
(125, "qc_reject", "qc", "review", "Porosity at W2"),
(124, "send_to_qc", "review", "qc", None),
(123, "qc_accept", "qc", "complete", None),
(120, "file", "complete", "filed", None),
):
transition_at(s, oid, days, action, frm, to, reason=reason)
qc_history_at(s, oid, 125, "rejected", "Porosity at W2")
qc_history_at(s, oid, 123, "accepted")
w.k["k_oldfiled"] = oid
# unit 006, on track: a draft (sent back twice long ago), ready, assigned, complete
dr = draft(shop, u[6], title="Draft spool")
w.k["k_draft"] = dr["id"]
transition_at(s, dr["id"], 400, "return_to_clerical", "ready", "draft", by="sup.wes", reason="Wrong department")
transition_at(s, dr["id"], 45, "return_to_clerical", "ready", "draft", by="sup.wes", reason="Missing drawing")
w.k["k_ready"] = ready_skipped(shop, u[6], title="Ready spool")["id"]
w.k["k_asg"] = to_station(shop, ready_skipped(shop, u[6], title="Assigned spool"), station="Stall 2")["id"]
w.k["k_complete"] = at_complete(shop, u[6])["id"]
return w
def by_key(rows: list[dict[str, Any]], key: str = "key") -> dict[str, dict[str, Any]]:
return {str(r[key]): r for r in rows}
def table(answer: dict[str, Any], key: str) -> dict[str, Any]:
found = [t for t in answer["tables"] if t["key"] == key]
assert found, (key, [t["key"] for t in answer["tables"]])
out: dict[str, Any] = found[0]
return out
def rows_by_pid(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
return {int(r["pid"]): r for r in t["rows"] if r.get("pid") is not None}
def unit_rows(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
return {int(r["unit_id"]): r for r in t["rows"] if r.get("lvl") == 1 and r.get("unit_id") is not None}
def project_rows(t: dict[str, Any]) -> dict[int, dict[str, Any]]:
return {int(r["project_id"]): r for r in t["rows"] if r.get("lvl") == 0 and r.get("project_id") is not None}
+17 -7
View File
@@ -14,6 +14,11 @@ def _sub(hub: events.EventHub, name: str, screen: str | None = None) -> events.S
return hub.subscribe(session_id=uuid.uuid4(), user_id=uuid.uuid4(), display_name=name, screen=screen)
def _bare(data: dict) -> dict:
"""A presence payload without the `since` times (they are the subscribe instants)."""
return {"users": [{k: v for k, v in u.items() if k != "since"} for u in data["users"]]}
async def _drain(q: asyncio.Queue[events.Item]) -> list[events.Item]:
await asyncio.sleep(0) # let call_soon_threadsafe callbacks run
out = []
@@ -29,12 +34,13 @@ def test_fan_out_presence_and_session_targeting() -> None:
b = _sub(hub, "Bo", "stall")
got_a = await _drain(a.queue)
assert [n for n, _ in got_a] == ["presence", "presence"]
assert got_a[-1][1] == {
assert _bare(got_a[-1][1]) == {
"users": [
{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None},
{"user_id": b.user_id, "display_name": "Bo", "screen": "stall", "packet_id": None},
{"user_id": a.user_id, "display_name": "Ann", "role": "", "screen": "admin", "packet_id": None},
{"user_id": b.user_id, "display_name": "Bo", "role": "", "screen": "stall", "packet_id": None},
]
}
assert all(u["since"] for u in got_a[-1][1]["users"])
await _drain(b.queue)
hub._deliver(json.dumps({"event": "changed", "collection": "packets", "id": 7}))
@@ -46,14 +52,18 @@ def test_fan_out_presence_and_session_targeting() -> None:
("changed", {"collection": "projects"}),
]
# A revoked session drops out of presence at once; the event itself goes to that session only.
hub._deliver(json.dumps({"event": "session", "session_id": b.session_id, "action": "revoked"}))
assert await _drain(a.queue) == []
assert await _drain(b.queue) == [("session", {"session_id": b.session_id, "action": "revoked"})]
users = [{"user_id": a.user_id, "display_name": "Ann", "role": "", "screen": "admin", "packet_id": None}]
got = await _drain(a.queue)
assert [(n, _bare(d)) for n, d in got] == [("presence", {"users": users})]
got_b = await _drain(b.queue)
assert got_b[0] == ("session", {"session_id": b.session_id, "action": "revoked"})
assert [n for n, _ in got_b] == ["session", "presence"]
hub.unsubscribe(b)
got = await _drain(a.queue)
users = [{"user_id": a.user_id, "display_name": "Ann", "screen": "admin", "packet_id": None}]
assert got == [("presence", {"users": users})]
assert [(n, _bare(d)) for n, d in got] == [("presence", {"users": users})]
asyncio.run(run())
+403
View File
@@ -0,0 +1,403 @@
"""Phase 8: the find bar filters applied by the server: status words, department, station, worker, the quick flags
(Mine per screen), the flag kinds, the sorts and the facets, on `GET /api/packets` and on the boards.
The world is built once per module. Most statuses past `ready` are set by SQL: the filters read the packet row only.
"""
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any
import pytest
from pmhelpers import get_ok, make_project, make_unit, module_shop, quick_draft, today_site
from pshelpers import needs_pg
from psycopg.types.json import Jsonb
from shophelpers import Shop, issue_co, ok, run, step
pytestmark = needs_pg
DRAFTS = ("d1", "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9")
STATUS_ORDER = ("draft", "warehouse", "ready", "assigned", "in_progress", "review", "qc", "complete", "filed")
@dataclass
class FilterWorld:
shop: Shop
k: dict[str, int] = field(default_factory=dict)
projects: dict[str, int] = field(default_factory=dict)
units: dict[str, int] = field(default_factory=dict)
def ids(self, *keys: str) -> set[int]:
return {self.k[x] for x in keys}
@property
def every(self) -> set[int]:
return set(self.k.values())
def but(self, *keys: str) -> set[int]:
return self.every - self.ids(*keys)
def title(key: str) -> str:
return f"ZX{key} spool"
@pytest.fixture(scope="module")
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[FilterWorld]:
with module_shop(tmp_path_factory.mktemp("filters")) as shop:
s = shop.settings
today = today_site()
w = FilterWorld(shop)
fa = make_project(s, "L700", "Filter project", customer="Acme", due=today + timedelta(days=60))
fb = make_project(s, "L60", "Second project")
w.projects = {"fa": fa, "fb": fb}
w.units = {
"f1": make_unit(s, fa, "001", due=today - timedelta(days=1)), # overdue
"f2": make_unit(s, fa, "002", due=today + timedelta(days=3)), # due within 7 days
"f3": make_unit(s, fa, "003", due=today + timedelta(days=30)),
"g1": make_unit(s, fb, "001"), # no due date anywhere
}
def mk(key: str, unit: str, send: str | None = None, **over: Any) -> dict[str, Any]:
pkt = quick_draft(shop, w.units[unit], title(key), n=len(w.k) + 1, send=send, **over)
w.k[key] = int(pkt["id"])
return pkt
# drafts: one hand draft, VISUAL drafts with each flag kind, an unconfirmed rev and a clean one. The rev
# confirmation lives in visual->>'rev_confirmed' (the drawing job writes it); revOk is the old name.
mk("d1", "f3")
visual = {
"d2": (["rev_mismatch"], "rev_mismatch", {}),
"d3": (["not_found"], "not_found", {}),
"d4": ([], "attached", {}),
"d5": (["lookup_failed"], "lookup_failed", {}),
"d6": (["drawing_needed"], "needed", {}),
"d7": (["not_released"], "not_released", {}),
"d8": (["hand_drawing"], "hand", {"revOk": True, "rev_confirmed": True}),
"d9": ([], "attached", {"revOk": True, "rev_confirmed": True}),
}
for key, (flags, state, vis) in visual.items():
mk(key, "f3")
run(
s,
"UPDATE packets SET source_kind = 'visual', flags = %s, drawing_state = %s::drawing_state,"
" visual = %s WHERE id = %s",
(flags, state, Jsonb(vis), w.k[key]),
)
# the warehouse: one waiting (overdue unit), one with an open short (unit due this week)
mk("w1", "f1", send="warehouse")
w2 = mk("w2", "f2", send="warehouse", bom=[{"item": 1, "qty": 2, "part_id": "PIPE-4", "description": "Pipe"}])
ok(
shop("wh.wade").post(
f"/api/packets/{w2['id']}/bom/1/short", json={"qty": 1, "reason": "On order", "expect_open": False}
)
)
# the shop: ready with an open change order, two at stations, one ready in 06 on the second project
issue_co(shop, mk("r1", "f3", send="03"), ["03"])
a1 = mk("a1", "f1", send="03")
ok(step(shop("sup.wes"), a1, "assign", station_id=shop.stall("03", "Stall 1"), welder_id=shop.uid("weld.andy")))
a2 = mk("a2", "f3", send="03")
a2 = ok(
step(shop("sup.wes"), a2, "assign", station_id=shop.stall("03", "Stall 2"), welder_id=shop.uid("dup.dave"))
)
ok(step(shop("dup.dave"), a2, "start"))
mk("s6", "g1", send="06")
# later statuses by SQL
for key, unit, status, routed in (
("rv1", "f2", "review", None),
("q1", "f3", "qc", None),
("c1", "f3", "complete", "databook"),
("c2", "f3", "complete", "shipping"),
("fl1", "f3", "filed", "databook"),
):
mk(key, unit, send="03")
run(
s,
"UPDATE packets SET status = %s::packet_status, routed_to = %s::route_kind, status_at = now()"
" WHERE id = %s",
(status, routed, w.k[key]),
)
yield w
def cards(w: FilterWorld, sam: str = "admin.ann", **params: Any) -> list[dict[str, Any]]:
out = get_ok(w.shop(sam), "/api/packets", **params)
got: list[dict[str, Any]] = out["packets"]
return got
def ids(w: FilterWorld, sam: str = "admin.ann", **params: Any) -> set[int]:
got = [int(c["id"]) for c in cards(w, sam, **params)]
assert len(got) == len(set(got)), got
return set(got)
# ================================================================ status, department, station, worker
STATUS_WORDS = {
"draft": DRAFTS,
"warehouse": ("w1", "w2"),
"ready": ("r1", "s6"),
"shop": ("r1", "s6", "a1", "a2", "rv1", "q1"),
"station": ("a1", "a2"),
"review": ("rv1",),
"qc": ("q1",),
"complete": ("c1", "c2"),
"filed": ("fl1",),
}
@pytest.mark.parametrize("word", [*STATUS_WORDS, "open"])
def test_status_words(world: FilterWorld, word: str) -> None:
want = world.but("fl1") if word == "open" else world.ids(*STATUS_WORDS[word])
assert ids(world, status=word) == want
def test_raw_status_list_still_works(world: FilterWorld) -> None:
assert ids(world, status="ready,review") == world.ids("r1", "s6", "rv1")
assert ids(world, status="assigned") == world.ids("a1")
assert ids(world, status="in_progress") == world.ids("a2")
def test_dept_station_worker(world: FilterWorld) -> None:
shop = world.shop
assert ids(world, dept="06") == world.ids("s6")
assert ids(world, dept="03") == world.but("s6")
assert ids(world, station_id=shop.stall("03", "Stall 1")) == world.ids("a1")
assert ids(world, station_id=shop.stall("03", "Stall 2")) == world.ids("a2")
assert ids(world, welder_id=shop.uid("weld.andy")) == world.ids("a1")
assert ids(world, welder_id=shop.uid("dup.dave")) == world.ids("a2")
assert ids(world, status="station", welder_id=shop.uid("dup.dave")) == world.ids("a2")
assert ids(world, status="draft", welder_id=shop.uid("dup.dave")) == set()
# ================================================================ quick flags
@pytest.mark.parametrize(
("flags", "keys"),
[
("co", ("r1",)),
("short", ("w2",)),
("overdue", ("w1", "a1")),
("week", ("w1", "a1", "w2", "rv1")),
("short,week", ("w2",)),
("co,short", ()),
],
)
def test_quick_flags(world: FilterWorld, flags: str, keys: tuple[str, ...]) -> None:
assert ids(world, flags=flags) == world.ids(*keys)
def test_mine_per_screen(world: FilterWorld) -> None:
shop = world.shop
clerical = world.ids(*DRAFTS, "c1")
assert ids(world, "clerk.clara", flags="mine") == clerical # the first screen of the roles
assert ids(world, "clerk.clara", flags="mine", mine_screen="clerical") == clerical
assert ids(world, "wh.wade", flags="mine", mine_screen="warehouse") == world.ids("w1", "w2")
assert ids(world, "sup.wes", flags="mine", mine_screen="supervisor") == world.but("s6")
assert ids(world, "multi.mia", flags="mine", mine_screen="supervisor") == world.ids("s6")
assert ids(world, "multi.mia", flags="mine") == clerical # Clerical comes first in the screen order
stall1 = shop.stall("03", "Stall 1")
assert ids(world, "weld.andy", flags="mine", mine_screen="stall", mine_station_id=stall1) == world.ids("a1")
assert ids(world, "weld.andy", flags="mine", mine_screen="stall") == set() # no station picked
assert ids(world, "qc.quinn", flags="mine", mine_screen="qc") == world.ids("q1")
assert ids(world, "pm.paula", flags="mine") == world.every
assert ids(world, "admin.ann", flags="mine", mine_screen="admin") == world.every
# mine with another flag narrows further
assert ids(world, "wh.wade", flags="mine,short", mine_screen="warehouse") == world.ids("w2")
def test_mine_never_widens(world: FilterWorld) -> None:
"""A screen the caller does not have gives nothing more than the caller's own screens would."""
got = ids(world, "clerk.clara", flags="mine", mine_screen="qc")
assert got in (set(), world.ids("q1"), world.ids(*DRAFTS, "c1"))
# ================================================================ flag kinds
@pytest.mark.parametrize(
("flag", "keys"),
[
("any", ("d1", "d2", "d3", "d5", "d6", "d7", "d8")),
("1", ("d1", "d2", "d3", "d5", "d6", "d7", "d8")),
("rev", ("d2",)),
("rev_mismatch", ("d2",)),
("unconf", ("d4",)),
("hand", ("d1", "d8")),
("hand_upload", ("d8",)),
("none", ("d7",)),
("vault", ("d3",)),
("read", ("d5",)),
("wait", ("d6",)),
],
)
def test_flag_kinds(world: FilterWorld, flag: str, keys: tuple[str, ...]) -> None:
assert ids(world, flag=flag) == world.ids(*keys)
@pytest.mark.parametrize("flag", ["no", "0"])
def test_not_flagged(world: FilterWorld, flag: str) -> None:
assert ids(world, flag=flag) == world.but("d1", "d2", "d3", "d5", "d6", "d7", "d8")
# ================================================================ sorts
def test_sort_updated_is_the_default(world: FilterWorld) -> None:
for got in (cards(world), cards(world, sort="updated")):
upd = [c["updated_at"] for c in got]
assert upd == sorted(upd, reverse=True)
assert [c["id"] for c in cards(world)] == [c["id"] for c in cards(world, sort="updated")]
def test_sort_project(world: FilterWorld) -> None:
got = cards(world, sort="project")
keys = [(c["project"]["number"].encode(), c["unit"]["number"].encode(), c["op"] or "") for c in got]
assert keys == sorted(keys) # C collation: byte order
assert got[0]["project"]["number"] == "L60" # "L60" sorts before "L700"
def test_sort_due(world: FilterWorld) -> None:
got = cards(world, sort="due")
due = [c["due_effective"] for c in got]
assert due == sorted(due, key=lambda d: (d is None, d or ""))
assert got[-1]["id"] == world.k["s6"] # no due date: last
assert {got[0]["id"], got[1]["id"]} == world.ids("w1", "a1") # the overdue unit first
def test_sort_status(world: FilterWorld) -> None:
order = [STATUS_ORDER.index(c["status"]) for c in cards(world, sort="status")]
assert order == sorted(order)
def test_sort_dept(world: FilterWorld) -> None:
got = cards(world, sort="dept")
depts = [c["dept"]["code"] for c in got]
assert depts == sorted(depts)
in03 = [c["updated_at"] for c in got if c["dept"]["code"] == "03"]
assert in03 == sorted(in03, reverse=True)
def test_sort_unknown_is_refused_or_default(world: FilterWorld) -> None:
r = world.shop("admin.ann").get("/api/packets", params={"sort": "drop table"})
assert r.status_code in (200, 400, 422), r.text
if r.status_code == 200:
assert [c["id"] for c in r.json()["packets"]] == [c["id"] for c in cards(world)]
# ================================================================ facets
def test_facets_follow_the_project_and_unit_only(world: FilterWorld) -> None:
shop = world.shop
plain = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"])
assert "facets" not in plain
out = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"], facets=1)
f = out["facets"]
assert [(x["name"], x["dept_code"]) for x in f["stations"]] == [("Stall 1", "03"), ("Stall 2", "03")]
assert {x["id"] for x in f["stations"]} == {shop.stall("03", "Stall 1"), shop.stall("03", "Stall 2")}
assert [x["display_name"] for x in f["workers"]] == ["Andy Welder", "Dave Dup"]
assert {x["id"] for x in f["workers"]} == {shop.uid("weld.andy"), shop.uid("dup.dave")}
# other filters do not narrow the facets
narrowed = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fa"], status="draft", facets=1)
assert narrowed["packets"] and all(c["status"] == "draft" for c in narrowed["packets"])
assert narrowed["facets"] == f
unit = get_ok(shop("admin.ann"), "/api/packets", unit_id=world.units["f1"], facets=1)["facets"]
assert [x["name"] for x in unit["stations"]] == ["Stall 1"]
assert [x["display_name"] for x in unit["workers"]] == ["Andy Welder"]
other = get_ok(shop("admin.ann"), "/api/packets", project_id=world.projects["fb"], facets=1)["facets"]
assert other == {"stations": [], "workers": []}
# ================================================================ the boards
def board_ids(b: dict[str, Any]) -> set[int]:
out = {int(c["id"]) for col in b["columns"].values() for c in col}
for d in b.get("depts", []):
for st in d["stations"]:
out |= {int(c["id"]) for c in st["packets"]}
return out
def steady(k: dict[str, Any]) -> dict[str, Any]:
return {key: v for key, v in k.items() if key != "elapsed_ms"}
def test_shop_board_filters_keep_the_kpis(world: FilterWorld) -> None:
shop = world.shop
wes = shop("sup.wes")
base = get_ok(wes, "/api/shop/board")
assert "facets" in base and set(base["facets"]) == {"stations", "workers"}
assert {"Stall 1", "Stall 2"} <= {x["name"] for x in base["facets"]["stations"]}
assert world.ids("r1", "a1", "a2", "rv1", "q1") <= board_ids(base)
cases: list[tuple[dict[str, Any], set[int]]] = [
({"station_id": shop.stall("03", "Stall 1")}, world.ids("a1")),
({"welder_id": shop.uid("dup.dave")}, world.ids("a2")),
({"q": "ZXa2"}, world.ids("a2")),
({"status": "review"}, world.ids("rv1")),
({"status": "station"}, world.ids("a1", "a2")),
({"flags": "co"}, world.ids("r1")),
({"flags": "week"}, world.ids("w1", "a1", "w2", "rv1")), # the warehouse column holds w1 and w2
({"q": "nothing-matches-this"}, set()),
]
for params, want in cases:
b = get_ok(wes, "/api/shop/board", **params)
assert board_ids(b) == want, params
# the KPIs follow the project and unit only (elapsed_ms is a running clock, so it moves between calls)
assert steady(b["kpis"]) == steady(base["kpis"]), params
assert "facets" in b
def test_stall_board_filters(world: FilterWorld) -> None:
shop = world.shop
andy = shop("weld.andy")
stall1 = shop.stall("03", "Stall 1")
base = get_ok(andy, "/api/shop/stall", station_id=stall1)
assert {c["id"] for c in base["mine"]} == world.ids("a1") and "facets" in base
assert get_ok(andy, "/api/shop/stall", station_id=stall1, q="ZXa1")["mine"][0]["id"] == world.k["a1"]
none = get_ok(andy, "/api/shop/stall", station_id=stall1, q="nothing-matches-this")
assert none["mine"] == [] and none["submitted"] == []
assert get_ok(andy, "/api/shop/stall", station_id=stall1, status="review")["mine"] == []
def test_warehouse_board_filters_keep_the_kpis(world: FilterWorld) -> None:
wade = world.shop("wh.wade")
base = get_ok(wade, "/api/warehouse/board")
assert world.ids("w1", "w2") <= board_ids(base) and "facets" in base
for params, want in (
({"flags": "short"}, world.ids("w2")),
({"q": "ZXw1"}, world.ids("w1")),
({"flags": "overdue"}, world.ids("w1")),
({"flags": "mine"}, world.ids("w1", "w2")),
):
b = get_ok(wade, "/api/warehouse/board", **params)
assert board_ids(b) & world.ids("w1", "w2") == want, params
assert b["kpis"] == base["kpis"], params
def test_qc_home_filters_keep_the_kpis(world: FilterWorld) -> None:
quinn = world.shop("qc.quinn")
base = get_ok(quinn, "/api/qc/home")
assert [c["id"] for c in base["columns"]["ready"]] == [world.k["q1"]] and "facets" in base
found = get_ok(quinn, "/api/qc/home", q="ZXq1")
assert [c["id"] for c in found["columns"]["ready"]] == [world.k["q1"]]
none = get_ok(quinn, "/api/qc/home", q="nothing-matches-this")
assert none["columns"]["ready"] == []
assert none["kpis"] == base["kpis"] and found["kpis"] == base["kpis"]
other = get_ok(quinn, "/api/qc/home", project_id=world.projects["fb"])
assert other["columns"]["ready"] == []
def test_filters_never_widen_access(world: FilterWorld) -> None:
"""A board still refuses who it refused; a filter cannot open another department's board."""
r = world.shop("multi.mia").get("/api/shop/board", params={"dept": "03", "status": "open"})
assert r.status_code == 403, r.text
r = world.shop("weld.andy").get("/api/shop/board", params={"flags": "mine"})
assert r.status_code == 403, r.text
+76 -1
View File
@@ -8,9 +8,13 @@ stamp per mode. Nothing here reads VISUAL or M-Files."""
from __future__ import annotations
import io
import json
import os
import re
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
@@ -31,7 +35,8 @@ from qchelpers import g
from shophelpers import Shop, open_shop, run
from packetstation.pdf import databook, stamps
from packetstation.pdf.style import clean
from packetstation.pdf.doc import PAD_H, Doc
from packetstation.pdf.style import L, clean, fmt_clock_hhmm, fmt_clock_hms, round_half_up
from packetstation.settings import Settings
pytestmark = needs_pg
@@ -40,6 +45,7 @@ NOW = datetime(2026, 10, 17, 21, 4, tzinfo=UTC)
SEAL = {"hash": "ab12cd34ef56" + "0" * 52, "key_id": "k1234567", "sealed_at": NOW}
SECTIONS = [1, 2, 3, 4, 5, 6, 7]
SIG_RE = re.compile(r"stamp ([0-9a-f]{16})")
DUMP_DIR = os.environ.get("DATABOOK_DUMP_DIR") # off unless set
@pytest.fixture
@@ -61,6 +67,17 @@ def build(shop: Shop, pid: int, mode: str = "preview", **kw: Any) -> Any:
)
def dump(name: str, built: Any, pkt: dict[str, Any]) -> None:
"""With DATABOOK_DUMP_DIR set to a folder, keep the book and the packet read there (for the side by side
comparison with the old build's data book). Off by default."""
out = DUMP_DIR
if not out:
return
Path(out).mkdir(parents=True, exist_ok=True)
(Path(out) / f"{name}.pdf").write_bytes(built.bytes)
(Path(out) / f"{name}.json").write_text(json.dumps({"packet": pkt, "index": built.index}, default=str, indent=1))
def index(built: Any) -> list[dict[str, Any]]:
keys = ("section", "doc", "ref", "rev", "page", "warn", "count")
return [{k: g(r, k) for k in keys} for r in built.index]
@@ -74,6 +91,7 @@ def test_data_book_preview(shop: Shop) -> None:
pkt = rich_complete(shop)
pid = pkt["id"]
built = build(shop, pid)
dump("preview", built, pkt)
data: bytes = built.bytes
assert data[:5] == b"%PDF-"
reader = pdf_reader(data)
@@ -141,6 +159,17 @@ def test_data_book_preview(shop: Shop) -> None:
assert heat in trace, heat
assert "open items" in trace.lower() and "item 3" in trace
# phase 8 comparison with the old build's book (each line one fix)
assert '(9" x 11")' in index_text # a letter sheet is 8.5 in wide: Math.round gives 9
assert re.search(r"\b\d\d:\d\d WELD TIME", flat(texts[0])), flat(texts[0]) # HH:MM as the old cover
weld_page = next(r for r in rows if str(r["doc"]).startswith("Weld log"))["page"]
assert re.search(r"Time recorded \d\d:\d\d:\d\d \(target 4\.5 h\)", flat(texts[int(weld_page) - 1]))
qc_page = next(r for r in rows if r["section"] == 4)["page"]
qc_lines = texts[int(qc_page) - 1].splitlines()
assert "AM" not in qc_lines and "PM" not in qc_lines, qc_lines # the date and time stay on one line
hist_page = next(r for r in rows if r["section"] == 7)["page"]
assert "CO-1 " in flat(texts[int(hist_page) - 1]) and "documents changed" in flat(texts[int(hist_page) - 1])
# metadata
meta = reader.metadata
assert meta.title == f"{pkt['unit']['project']['number']} Lot 001 Manufacturing Data Book - Spool 7 outlet"
@@ -199,3 +228,49 @@ def test_data_book_with_an_itp_document_has_no_itp_warning(shop: Shop) -> None:
s5 = [r for r in rows if r["section"] == 5]
assert s5[0]["doc"].startswith("ITP"), s5
assert not any(r["doc"] == "No signed ITP filed for this unit" for r in s5)
# ---------------------------------------------------------------- phase 8: against the old build's book
def test_co_summary_of_a_manual_change_without_sheets_reads_documents_changed() -> None:
co: dict[str, Any] = {"origin": "manual", "review": "approved", "reason": "Nozzle moved", "items": []}
assert databook.co_summary(co) == "documents changed" # the reason has its own column, as in coSummary()
co["items"] = [{"old_dwg": "D-5001", "old_rev": "A", "new_rev": "B"}]
assert databook.co_summary(co) == "D-5001 Rev A -> Rev B"
def test_clock_and_sheet_size_forms_of_the_old_build() -> None:
assert fmt_clock_hms(48_112) == "00:00:48" and fmt_clock_hms(3_725_000) == "01:02:05"
assert fmt_clock_hhmm(48_112) == "00:00" and fmt_clock_hhmm(37_800_000) == "10:30"
assert fmt_clock_hhmm(None) == "00:00"
assert round_half_up(612 / 72) == 9 and round_half_up(792 / 72) == 11 and round_half_up(2448 / 72) == 34
def _rows_height(rows: list[list[Any]], cols: dict[int, dict[str, Any]]) -> float:
d = Doc(("T", "S"))
return d.table(None, rows, 200.0, cols) - 200.0
def test_table_cells_wrap_where_autotable_did() -> None:
one_line = 8.3 * 1.15 + 2 * 4.2
# 79.4 pt of text in an 86 pt column: one line in the old book (autotable's 1 pt slack, jsPDF's widths)
assert abs(_rows_height([["10/18/2026 11:32 AM"]], {0: {"w": 86}}) - one_line) < 0.01
# clearly too long still wraps
assert _rows_height([["10/18/2026 11:32 AM and more"]], {0: {"w": 86}}) > one_line * 1.5
def test_table_heads_sit_left_over_centred_columns() -> None:
from pypdf import PdfReader
d = Doc(("T", "S"))
d.table(["ITEM", "QTY", "TEXT"], [["1", "2", "x"]], 200.0, {0: {"w": 30, "align": "center"}, 1: {"w": 30}})
xs: dict[str, float] = {}
def visit(text: str, cm: Any, tm: Any, font: Any, size: Any) -> None:
if text.strip() in ("ITEM", "QTY"):
xs[text.strip()] = float(tm[4])
PdfReader(io.BytesIO(d.finish())).pages[0].extract_text(visitor_text=visit)
assert xs["ITEM"] == pytest.approx(L + PAD_H, abs=0.5), xs # autotable column styles style the body only
assert xs["QTY"] == pytest.approx(L + 30 + PAD_H, abs=0.5), xs
+137
View File
@@ -0,0 +1,137 @@
"""Phase 8 server smoke: one small world through every new read (the find bar on the list and the boards, the PM
reports, the exports) so the server agent's shapes are exercised end to end. The detailed checks live in
test_search.py, test_filters.py, test_pm_api.py, test_pm_export.py and test_presence.py."""
from __future__ import annotations
import io
from collections.abc import Iterator
import pytest
from fakedir import FakeDirectory
from pshelpers import app_conn, needs_pg
from pypdf import PdfReader
from shophelpers import Shop, draft, make_unit, ok, open_shop, ready_packet, run, set_running_since, start, to_station
from shophelpers import to_warehouse as send_to_wh
from packetstation.settings import Settings
pytestmark = needs_pg
@pytest.fixture
def shop(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[Shop]:
yield from open_shop(pg_settings, fake_dir)
def test_find_bar_boards_and_pm_reports(shop: Shop) -> None:
settings = shop.settings
project_id, u1 = make_unit(settings, "L670", "052")
with app_conn(settings) as conn:
u2 = conn.execute(
"INSERT INTO units (project_id, lot, name, status, due) VALUES (%s, '053', 'Unit two', 'open',"
" current_date - 2) RETURNING id",
(project_id,),
).fetchone()[0]
conn.execute("UPDATE projects SET due = current_date + 30 WHERE id = %s", (project_id,))
p_draft = draft(shop, u1, title="Inlet nozzle =SUM(A1)")
p_wh = send_to_wh(shop, draft(shop, u1, drawing="D-7000"))
ok(
shop("wh.wade").post(
f"/api/packets/{p_wh['id']}/wh",
json={"stage": "hold", "reason": "Waiting on purchasing / PO", "expect_stage": "incoming"},
)
)
p_st = start(shop, to_station(shop, ready_packet(shop, u2)))
set_running_since(settings, p_st["id"], 6 * 3600)
with app_conn(settings) as conn:
line = conn.execute("SELECT id FROM bom_lines WHERE packet_id = %s AND item = 2", (p_wh["id"],)).fetchone()[0]
conn.execute(
"INSERT INTO bom_shorts (packet_id, bom_line_id, qty, qty_open, reason, ref, eta)"
" VALUES (%s, %s, 2, 2, 'Not in stock', 'PO-881', current_date - 1)",
(p_wh["id"], line),
)
conn.execute(
"INSERT INTO qc_history (packet_id, at, result, inspector, note, dept_code, snapshot)"
" VALUES (%s, now() - interval '3 days', 'rejected', 'Quinn QC', 'NCR-12 · undercut', '03', '{}')",
(p_st["id"],),
)
co = conn.execute(
"INSERT INTO change_orders (packet_id, co_no, origin, review, ecn, reason, issued_at)"
" VALUES (%s, 1, 'manual', 'approved', 'ECN-77', 'Rev C', now() - interval '4 days') RETURNING id",
(p_st["id"],),
).fetchone()[0]
conn.execute("INSERT INTO co_required_depts (co_id, dept_code) VALUES (%s, '03')", (co,))
run(settings, "UPDATE packets SET wps = 'WPS-99' WHERE id = %s", (p_draft["id"],))
sup = shop("sup.wes")
# The find bar on the list
got = ok(sup.get("/api/packets", params={"q": "lot 52", "project_id": project_id, "facets": 1}))
ids = {c["id"] for c in got["packets"]}
assert p_draft["id"] in ids and p_st["id"] not in ids, ids
assert len(got["facets"]["workers"]) == 1 and len(got["facets"]["stations"]) == 1
assert ok(sup.get("/api/packets", params={"q": "HT-20"}))["packets"][0]["id"] == p_st["id"]
assert [c["id"] for c in ok(sup.get("/api/packets", params={"q": "ECN-77"}))["packets"]] == [p_st["id"]]
assert [c["id"] for c in ok(sup.get("/api/packets", params={"status": "station"}))["packets"]] == [p_st["id"]]
shorts = ok(sup.get("/api/packets", params={"flags": "short"}))["packets"]
assert [c["id"] for c in shorts] == [p_wh["id"]]
mine = ok(sup.get("/api/packets", params={"flags": "mine", "mine_screen": "supervisor"}))["packets"]
assert {c["id"] for c in mine} == {p_draft["id"], p_wh["id"], p_st["id"]}
over = ok(sup.get("/api/packets", params={"flags": "overdue"}))["packets"]
assert [c["id"] for c in over] == [p_st["id"]]
for sort in ("updated", "project", "due", "status", "dept"):
assert len(ok(sup.get("/api/packets", params={"sort": sort, "project_id": project_id}))["packets"]) == 3
hand = ok(sup.get("/api/packets", params={"flag": "hand"}))["packets"]
assert {c["id"] for c in hand} == {p_draft["id"]}
# The boards keep their KPIs on the project scope
b = ok(sup.get("/api/shop/board", params={"q": "nothing-matches-this"}))
assert b["kpis"]["at_stations"] == 1 and not b["depts"][0]["stations"][0]["packets"]
assert "facets" in b
wh = ok(shop("wh.wade").get("/api/warehouse/board", params={"flags": "short"}))
assert [c["id"] for c in wh["columns"]["hold"]] == [p_wh["id"]] and "facets" in wh
qc = ok(shop("qc.quinn").get("/api/qc/home", params={"q": "zzz"}))
assert qc["kpis"]["waiting"] == 0 and "facets" in qc
stall = ok(shop("weld.andy").get("/api/shop/stall", params={"station_id": shop.stall(), "flags": "mine"}))
assert [c["id"] for c in stall["mine"]] == [p_st["id"]]
# Project Management
pmc = shop("pm.paula")
ov = ok(pmc.get("/api/pm/overview", params={"period": "30"}))
k = {x["key"]: x["n"] for x in ov["kpis"]}
assert k["open"] == 3 and k["holds"] == 1 and k["shorts"] == 1 and k["cos"] == 1 and k["rejects"] == 1, k
assert k["late_units"] == 1, k
assert ov["scope"]["label"] == "All projects" and ov["summary"][0].startswith("3 open packets")
assert sum(s["count"] for s in ov["pipeline"]) == 3
assert [(r["cells"][0], r["health"]) for r in ov["attention"]["rows"]] == [("L670052", "risk"), ("L670053", "late")]
pr = ok(pmc.get("/api/pm/projects", params={"expand": "all"}))
assert [r["lvl"] for r in pr["tables"][0]["rows"]][:3] == [0, 1, 1]
ag = ok(pmc.get("/api/pm/aging"))
assert [t["key"] for t in ag["tables"]] == ["aging_steps", "aging_open", "aging_history"]
hr = ok(pmc.get("/api/pm/hours"))
over_rows = hr["tables"][3]["rows"]
assert [r["pid"] for r in over_rows] == [p_st["id"]], hr
ql = ok(pmc.get("/api/pm/quality"))
assert [len(t["rows"]) for t in ql["tables"]][:4] == [1, 1, 1, 1], ql
fd = ok(pmc.get("/api/pm/find", params={"sort": "project", "project_id": project_id}))
assert fd["scope"]["label"] == "Project L670" and len(fd["packets"]) == 3
r = pmc.get("/api/pm/export.csv", params={"table": "all"})
assert r.status_code == 200, r.text
text = r.content.decode("utf-8")
assert text.startswith("\ufeffPESCO Packet Station — All reports\r\n")
assert "attachment" in r.headers["content-disposition"]
assert pmc.get("/api/pm/export.csv", params={"table": "nope"}).status_code == 404
r = pmc.get("/api/pm/report.pdf")
assert r.status_code == 200 and r.content.startswith(b"%PDF")
reader = PdfReader(io.BytesIO(r.content))
assert len(reader.pages) > 5
assert "PRODUCTION STATUS REPORT" in reader.pages[0].extract_text()
assert all("Controlled copy" in pg.extract_text() for pg in reader.pages)
with app_conn(settings) as conn:
kinds = [x[0] for x in conn.execute("SELECT kind FROM print_log ORDER BY id")]
acts = [x[0] for x in conn.execute("SELECT action FROM audit_events WHERE action = 'report exported'")]
assert kinds == ["report-csv", "report-pdf"] and len(acts) == 2
assert shop("clerk.clara").get("/api/pm/overview").status_code == 200
+372
View File
@@ -0,0 +1,372 @@
"""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
+231
View File
@@ -0,0 +1,231 @@
"""Phase 8: the Project Management exports. The CSV per table and for every table (the header rows, the formula
guard, the BOM and CRLF), the production status report PDF (pages, cover, the controlled copy stamp on every page),
and one print_log row plus one audit row `report exported` for each export.
The PM world (pmhelpers.py) is built once for the module; one draft's title starts with `=`.
"""
from __future__ import annotations
import csv
import io
import re
from collections.abc import Iterator
from typing import Any
import pytest
from closehelpers import flat, page_texts, pdf_pages, q1, qall
from pmhelpers import (
FORMULA_TITLE,
HEADS,
PERIODS,
PmWorld,
anon,
module_shop,
pm_world,
titles,
)
from pshelpers import needs_pg
from shophelpers import run
pytestmark = needs_pg
TABLE_KEYS = tuple(HEADS) # projects, aging_*, hours_*, quality_*, jobs
@pytest.fixture(scope="module")
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[PmWorld]:
with module_shop(tmp_path_factory.mktemp("pm_export")) as shop:
w = pm_world(shop)
run(shop.settings, "UPDATE packets SET title = %s WHERE id = %s", (FORMULA_TITLE, w.k["k_draft"]))
yield w
def export(w: PmWorld, table: str, sam: str = "pm.paula", **params: Any) -> Any:
return w.shop(sam).get("/api/pm/export.csv", params={"table": table, **params})
def parse(body: bytes) -> list[list[str]]:
assert body.startswith(b"\xef\xbb\xbf"), body[:10] # the BOM, for Excel
assert b"\r\n" in body and body.count(b"\n") == body.count(b"\r\n"), "every line ends CRLF"
return list(csv.reader(io.StringIO(body.decode("utf-8-sig"), newline="")))
def filename(r: Any) -> str:
cd = r.headers["content-disposition"]
m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', cd)
assert m, cd
return m.group(1)
def print_rows(w: PmWorld, kind: str) -> list[Any]:
return qall(
w.settings,
"SELECT what, pages, by_name, packet_id, unit_id FROM print_log WHERE kind = %s ORDER BY id",
(kind,),
)
def audit_rows(w: PmWorld) -> list[dict[str, Any]]:
return [
r[0] for r in qall(w.settings, "SELECT detail FROM audit_events WHERE action = 'report exported' ORDER BY id")
]
def header_ok(rows: list[list[str]], name: str, label: str = "All projects", period: str = "90") -> None:
assert rows[0] == [f"PESCO Packet Station — {name}"], rows[0]
assert rows[1] == ["Scope", label]
assert rows[2] == ["History period", PERIODS[period]]
assert rows[3][0] == "Exported" and rows[3][2] == "by" and rows[3][3] == "Paula PM" and rows[3][1]
assert rows[4] == [] or rows[4] == [""]
def table_at(rows: list[list[str]], title: str) -> tuple[list[str], list[list[str]]]:
"""The head and the body rows of the table titled `title` in a parsed CSV."""
i = rows.index([title])
head = rows[i + 1]
body = []
for r in rows[i + 2 :]:
if r in ([], [""]):
break
body.append(r)
return head, body
# ================================================================ CSV
@pytest.mark.parametrize("key", TABLE_KEYS)
def test_csv_per_table(world: PmWorld, key: str) -> None:
r = export(world, key)
assert r.status_code == 200, r.text
assert r.headers["content-type"].startswith("text/csv")
assert "attachment" in r.headers["content-disposition"]
name = filename(r)
assert name.startswith("PacketStation_") and name.endswith(f"_{world.today.isoformat()}.csv"), name
assert re.fullmatch(r"PacketStation_[A-Za-z0-9_]+_\d{4}-\d{2}-\d{2}\.csv", name), name
rows = parse(r.content)
title = titles()[key]
assert rows[0][0].startswith("PESCO Packet Station — ")
head, body = table_at(rows, title)
assert head == HEADS[key]
assert all(len(b) == len(head) for b in body), key
def test_csv_jobs_and_the_formula_guard(world: PmWorld) -> None:
r = export(world, "jobs")
assert r.status_code == 200, r.text
assert filename(r) == f"PacketStation_Jobs_{world.today.isoformat()}.csv"
rows = parse(r.content)
header_ok(rows, "Jobs")
head, body = table_at(rows, titles()["jobs"])
assert head == HEADS["jobs"]
assert len(body) == 14 # 12 open and 2 filed
col = head.index("Title")
assert "'" + FORMULA_TITLE in [b[col] for b in body]
assert FORMULA_TITLE not in [b[col] for b in body]
every = [c for b in rows for c in b]
assert not any(c[:1] in ("=", "+", "-", "@", "\t") for c in every if c), "the formula guard on every cell"
def test_csv_all(world: PmWorld) -> None:
r = export(world, "all")
assert r.status_code == 200, r.text
assert filename(r) == f"PacketStation_All_reports_{world.today.isoformat()}.csv"
rows = parse(r.content)
header_ok(rows, "All reports")
for key in TABLE_KEYS:
head, _ = table_at(rows, titles()[key])
assert head == HEADS[key], key
_, units = table_at(rows, titles()["projects"])
assert len(units) >= 2 + 8 # every project row and every unit (expand all)
def test_csv_follows_scope_and_period(world: PmWorld) -> None:
pa = world.projects["L670"]
r = export(world, "quality_sentback", project_id=pa, period="365")
assert r.status_code == 200, r.text
rows = parse(r.content)
assert rows[1] == ["Scope", "Project L670"]
assert rows[2] == ["History period", PERIODS["365"]]
_, body = table_at(rows, titles("365")["quality_sentback"])
assert len(body) == 4
def test_csv_unknown_table(world: PmWorld) -> None:
r = export(world, "salaries")
assert r.status_code == 404 and r.json()["code"] == "not_found", r.text
def test_csv_is_logged(world: PmWorld) -> None:
before_print = len(print_rows(world, "report-csv"))
before_audit = len(audit_rows(world))
assert export(world, "jobs").status_code == 200
rows = print_rows(world, "report-csv")
assert len(rows) == before_print + 1
what, pages, by_name, packet_id, unit_id = rows[-1]
assert what.endswith(" · CSV · All projects") and by_name == "Paula PM"
assert packet_id is None and unit_id is None and pages is None
audit = audit_rows(world)
assert len(audit) == before_audit + 1
detail = audit[-1]
assert set(detail) >= {"name", "format", "scope", "period"}
assert str(detail["format"]).lower() == "csv" and detail["scope"] == "All projects"
assert detail["period"] in ("90", PERIODS["90"])
def test_export_needs_a_session(world: PmWorld) -> None:
c = anon(world.shop)
assert c.get("/api/pm/export.csv", params={"table": "all"}).status_code == 401
assert c.get("/api/pm/report.pdf").status_code == 401
assert world.shop("clerk.clara").get("/api/pm/export.csv", params={"table": "jobs"}).status_code == 200
# ================================================================ PDF
def test_report_pdf(world: PmWorld) -> None:
before = len(print_rows(world, "report-pdf"))
before_audit = len(audit_rows(world))
r = world.shop("pm.paula").get("/api/pm/report.pdf")
assert r.status_code == 200, r.text
assert r.headers["content-type"].startswith("application/pdf")
assert r.headers["content-disposition"].startswith("inline")
assert filename(r) == f"PESCO_Production_Status_{world.today.isoformat()}.pdf"
data = r.content
assert data[:5] == b"%PDF-"
pages = pdf_pages(data)
assert pages > 5
texts = [flat(t) for t in page_texts(data)]
assert "PRODUCTION STATUS REPORT" in texts[0]
assert "Packet Station · All projects" in texts[0]
for i, t in enumerate(texts):
assert "Controlled copy" in t, f"page {i + 1} has no stamp"
body = texts[1:]
for i, t in enumerate(body):
assert "Production status report" in t, i
assert "CONFIDENTIAL & PROPRIETARY" in t, i
every = " ".join(texts)
for part in ("SUMMARY", "PROJECTS", "FLOW", "LABOR", "ISSUES"):
assert part in every, part
assert "L670" in every
# logged once with the page count
rows = print_rows(world, "report-pdf")
assert len(rows) == before + 1
what, logged_pages, by_name, packet_id, _ = rows[-1]
assert what == "Production status report · PDF · All projects"
assert logged_pages == pages and by_name == "Paula PM" and packet_id is None
audit = audit_rows(world)
assert len(audit) == before_audit + 1 and str(audit[-1]["format"]).lower() == "pdf"
def test_report_pdf_download_and_scope(world: PmWorld) -> None:
r = world.shop("pm.paula").get(
"/api/pm/report.pdf", params={"download": 1, "project_id": world.projects["L670"], "period": "all"}
)
assert r.status_code == 200, r.text
assert r.headers["content-disposition"].startswith("attachment")
first = flat(page_texts(r.content)[0])
assert "Packet Station · Project L670" in first
row = q1(world.settings, "SELECT what FROM print_log WHERE kind = 'report-pdf' ORDER BY id DESC LIMIT 1")
assert row[0] == "Production status report · PDF · Project L670"
+342
View File
@@ -0,0 +1,342 @@
"""Phase 8: presence. Who is on which screen and packet, with the role shown; locked sessions drop out and come
back on unlock; revoked sessions are gone at once. Driven through the EventHub directly (as test_events.py does)
and through the API (`/api/events`, `/api/auth/lock`, `/api/auth/unlock`, the idle lock in `resolve`).
"""
from __future__ import annotations
import asyncio
import json
import uuid
from collections.abc import AsyncIterator, Iterator
from typing import Any
import pytest
from fakedir import FAKE_PASSWORD, FakeDirectory
from fastapi.testclient import TestClient
from pshelpers import ORIGIN_HEADERS, needs_pg, sign_in
from packetstation import db, events
from packetstation.settings import Settings
USER_KEYS = {"user_id", "display_name", "role", "screen", "packet_id", "since"}
def _sub(
hub: events.EventHub,
name: str,
*,
screen: str | None = None,
packet_id: int | None = None,
role: str = "Supervisor",
user_id: Any = None,
session_id: Any = None,
) -> events.Subscriber:
return hub.subscribe(
session_id=session_id or uuid.uuid4(),
user_id=user_id or uuid.uuid4(),
display_name=name,
screen=screen,
packet_id=packet_id,
role=role,
)
async def _drain(q: asyncio.Queue[events.Item]) -> list[events.Item]:
await asyncio.sleep(0) # let call_soon_threadsafe callbacks run
out = []
while not q.empty():
out.append(q.get_nowait())
return out
def _on(hub: events.EventHub, packet_id: int) -> list[tuple[str, str, str | None]]:
return [(u["display_name"], u["role"], u["screen"]) for u in hub.presence()["users"] if u["packet_id"] == packet_id]
def _names(hub: events.EventHub) -> list[str]:
return [u["display_name"] for u in hub.presence()["users"]]
def _session(hub: events.EventHub, sub: events.Subscriber, action: str) -> None:
hub._deliver(json.dumps({"event": "session", "session_id": sub.session_id, "action": action}))
# ================================================================ the hub
def test_two_users_on_one_packet_see_each_other() -> None:
async def run() -> None:
hub = events.EventHub()
wes = _sub(hub, "Wes Super", screen="supervisor", packet_id=7, role="Supervisor")
andy = _sub(hub, "Andy Welder", screen="stall", packet_id=7, role="Weld Stall")
_sub(hub, "Clara Clerk", screen="clerical", role="Clerical")
users = hub.presence()["users"]
assert all(set(u) == USER_KEYS for u in users), users
assert all(u["since"] for u in users)
assert sorted(_on(hub, 7)) == [
("Andy Welder", "Weld Stall", "stall"),
("Wes Super", "Supervisor", "supervisor"),
]
last = (await _drain(wes.queue))[-1]
assert last[0] == "presence" and {u["display_name"] for u in last[1]["users"]} == {
"Wes Super",
"Andy Welder",
"Clara Clerk",
}
assert (await _drain(andy.queue))[-1] == last
asyncio.run(run())
def test_one_entry_per_user_screen_and_packet() -> None:
async def run() -> None:
hub = events.EventHub()
uid = uuid.uuid4()
_sub(hub, "Wes Super", screen="supervisor", packet_id=7, user_id=uid)
_sub(hub, "Wes Super", screen="supervisor", packet_id=7, user_id=uid) # a second tab
_sub(hub, "Wes Super", screen="supervisor", packet_id=8, user_id=uid)
assert len(_on(hub, 7)) == 1 and len(_on(hub, 8)) == 1
assert _names(hub) == ["Wes Super", "Wes Super"]
asyncio.run(run())
def test_leaving_drops_out() -> None:
async def run() -> None:
hub = events.EventHub()
wes = _sub(hub, "Wes Super", packet_id=7)
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
await _drain(wes.queue)
hub.unsubscribe(andy)
got = await _drain(wes.queue)
assert got and got[-1][0] == "presence"
assert [u["display_name"] for u in got[-1][1]["users"]] == ["Wes Super"]
assert _on(hub, 7) == [("Wes Super", "Supervisor", None)]
asyncio.run(run())
def test_locked_hides_and_unlocked_brings_back() -> None:
async def run() -> None:
hub = events.EventHub()
wes = _sub(hub, "Wes Super", packet_id=7)
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
await _drain(wes.queue)
await _drain(andy.queue)
_session(hub, andy, "locked")
assert _names(hub) == ["Wes Super"]
to_wes = await _drain(wes.queue)
assert [n for n, _ in to_wes] == ["presence"] # the others get presence, never the session event
assert [u["display_name"] for u in to_wes[-1][1]["users"]] == ["Wes Super"]
to_andy = await _drain(andy.queue)
assert ("session", {"session_id": andy.session_id, "action": "locked"}) in to_andy
_session(hub, andy, "unlocked")
assert sorted(_names(hub)) == ["Andy Welder", "Wes Super"]
to_wes = await _drain(wes.queue)
assert to_wes and to_wes[-1][0] == "presence" and len(to_wes[-1][1]["users"]) == 2
assert ("session", {"session_id": andy.session_id, "action": "unlocked"}) in await _drain(andy.queue)
asyncio.run(run())
def test_a_locked_session_hides_only_itself() -> None:
async def run() -> None:
hub = events.EventHub()
uid = uuid.uuid4()
tablet = _sub(hub, "Wes Super", packet_id=7, user_id=uid)
_sub(hub, "Wes Super", packet_id=7, user_id=uid) # the same person on a second device
_session(hub, tablet, "locked")
assert _on(hub, 7) == [("Wes Super", "Supervisor", None)]
asyncio.run(run())
def test_revoked_is_gone_at_once() -> None:
async def run() -> None:
hub = events.EventHub()
wes = _sub(hub, "Wes Super", packet_id=7)
andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall")
await _drain(wes.queue)
_session(hub, andy, "revoked")
assert _names(hub) == ["Wes Super"] # before the stream has ended
got = await _drain(wes.queue)
assert got and got[-1][0] == "presence" and len(got[-1][1]["users"]) == 1
assert ("session", {"session_id": andy.session_id, "action": "revoked"}) in await _drain(andy.queue)
asyncio.run(run())
def test_stream_forwards_lock_and_keeps_going() -> None:
async def run() -> list[str]:
hub = events.EventHub()
sub = _sub(hub, "Wes Super", screen="supervisor")
await _drain(sub.queue)
async def connected() -> bool:
return False
gen = events.stream(hub, sub, hello={"session_id": sub.session_id}, is_disconnected=connected, ping_seconds=5)
chunks = [await gen.__anext__()]
_session(hub, sub, "locked")
for _ in range(3):
chunks.append(await asyncio.wait_for(gen.__anext__(), 2))
if chunks[-1].startswith("event: session"):
break
_session(hub, sub, "revoked")
rest = [c async for c in gen]
assert hub.presence() == {"users": []}
return chunks + rest
chunks = asyncio.run(run())
assert chunks[0].startswith("event: hello\n")
names = [c.split("\n", 1)[0] for c in chunks[1:]]
assert "event: session" in names # the lock reached the locked session's own stream
assert any('"action":"locked"' in c for c in chunks)
def test_presence_by_screen() -> None:
async def run() -> None:
hub = events.EventHub()
_sub(hub, "Wes Super", screen="supervisor")
_sub(hub, "Lou Lead", screen="supervisor")
_sub(hub, "Quinn QC", screen="qc", role="Q.C.")
locked = _sub(hub, "Andy Welder", screen="stall", role="Weld Stall")
_session(hub, locked, "locked")
by = hub.presence_by_screen()
assert sorted(u["display_name"] for u in by["supervisor"]) == ["Lou Lead", "Wes Super"]
assert [u["display_name"] for u in by["qc"]] == ["Quinn QC"]
assert not by.get("stall")
asyncio.run(run())
# ================================================================ through the API
@pytest.fixture
def client(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]:
from packetstation.main import create_app
with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as c:
yield c
def _signed(client: TestClient, sam: str) -> tuple[TestClient, str]:
c = TestClient(client.app, headers=ORIGIN_HEADERS)
r = sign_in(c, sam)
assert r.status_code == 200, r.text
return c, str(r.json()["session"]["id"])
@needs_pg
def test_events_stream_carries_the_role(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""The stream body is replaced by one that answers the hub's presence and ends, so the request returns."""
from packetstation.api import events as events_api
async def snapshot(
hub: events.EventHub, sub: events.Subscriber, *, hello: dict[str, Any], **_: Any
) -> AsyncIterator[str]:
try:
yield events.format_sse("presence", hub.presence())
finally:
hub.unsubscribe(sub)
monkeypatch.setattr(events_api, "stream", snapshot)
def users(c: TestClient, **params: Any) -> list[dict[str, Any]]:
r = c.get("/api/events", params=params)
assert r.status_code == 200, r.text
line = next(x for x in r.text.splitlines() if x.startswith("data: "))
out: list[dict[str, Any]] = json.loads(line[6:])["users"]
return out
wes, _ = _signed(client, "sup.wes")
me = users(wes, screen="supervisor", packet_id=5)
assert [(u["display_name"], u["role"], u["screen"], u["packet_id"]) for u in me] == [
("Wes Super", "Supervisor", "supervisor", 5)
]
assert me[0]["since"]
# a screen the user does not have: no screen, the first role's label
other = users(wes, screen="qc")
assert [(u["role"], u["screen"]) for u in other] == [("Supervisor", None)]
for sam, screen, role in (
("pm.paula", "pm", "Project Mgmt"),
("weld.andy", "stall", "Weld Stall"),
("clerk.clara", "clerical", "Clerical"),
("wh.wade", "warehouse", "Warehouse"),
("qc.quinn", "qc", "Q.C."),
("admin.ann", "admin", "Admin"),
):
c, _ = _signed(client, sam)
assert [u["role"] for u in users(c, screen=screen)] == [role], sam
paula, _ = _signed(client, "pm.paula")
assert [u["role"] for u in users(paula)] == ["Project Mgmt"] # no screen: the primary role
async def _wait_session(q: asyncio.Queue[events.Item], action: str) -> dict[str, Any]:
while True:
name, data = await asyncio.wait_for(q.get(), timeout=10)
if name == "session" and data.get("action") == action:
return data
@needs_pg
def test_lock_and_unlock_publish_session_events(client: TestClient, pg_settings: Settings) -> None:
wes, sid = _signed(client, "sup.wes")
async def run() -> None:
hub = events.EventHub(connect=lambda: db.connect_as("app", pg_settings, autocommit=True))
sub = _sub(hub, "Wes Super", screen="supervisor", packet_id=3, session_id=sid)
other = _sub(hub, "Andy Welder", screen="stall", packet_id=3, role="Weld Stall")
hub.start()
try:
for _ in range(100):
if hub.connected:
break
await asyncio.sleep(0.05)
assert hub.connected
r = await asyncio.to_thread(wes.post, "/api/auth/lock")
assert r.status_code == 204, r.text
data = await _wait_session(sub.queue, "locked")
assert data["session_id"] == sid
assert _names(hub) == ["Andy Welder"]
assert other.queue.qsize() >= 1 # the others got a presence update
r = await asyncio.to_thread(
wes.post, "/api/auth/unlock", json={"factor": "password", "secret": FAKE_PASSWORD}
)
assert r.status_code == 200, r.text
await _wait_session(sub.queue, "unlocked")
assert sorted(_names(hub)) == ["Andy Welder", "Wes Super"]
finally:
await asyncio.to_thread(hub.stop)
asyncio.run(run())
@needs_pg
def test_idle_lock_publishes(client: TestClient, pg_settings: Settings) -> None:
wes, sid = _signed(client, "sup.wes")
async def run() -> None:
hub = events.EventHub(connect=lambda: db.connect_as("app", pg_settings, autocommit=True))
sub = _sub(hub, "Wes Super", session_id=sid)
hub.start()
try:
for _ in range(100):
if hub.connected:
break
await asyncio.sleep(0.05)
assert hub.connected
with db.connect_as("app", pg_settings, autocommit=True) as conn:
conn.execute("UPDATE sessions SET last_seen_at = now() - interval '5 minutes' WHERE id = %s", (sid,))
r = await asyncio.to_thread(wes.get, "/api/auth/me")
assert r.status_code == 200 and r.json()["session"]["locked"] is True, r.text
await _wait_session(sub.queue, "locked")
assert _names(hub) == []
finally:
await asyncio.to_thread(hub.stop)
asyncio.run(run())
+228
View File
@@ -0,0 +1,228 @@
"""Phase 8: the find bar search (`q=`), one test per field of the vocabulary, on `GET /api/packets`.
Every word must match some field; a word matches by substring, case insensitive. Each distinctive value below is
on one packet only, so a search returns exactly the packets that carry it. The world is built once per module.
"""
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any
import pytest
from pmhelpers import add_alias, make_project, make_unit, module_shop, quick_draft
from pshelpers import needs_pg
from shophelpers import Shop, ok, run, step
pytestmark = needs_pg
LONG = "Q" * 60 # a title of exactly 60 characters
@dataclass
class SearchWorld:
shop: Shop
k: dict[str, int] = field(default_factory=dict)
every: set[int] = field(default_factory=set)
def ids(self, *keys: str) -> set[int]:
return {self.k[x] for x in keys}
def make(
shop: Shop,
w: SearchWorld,
key: str,
unit_id: int,
title: str,
*,
ready_dept: str | None = None,
**over: Any,
) -> dict[str, Any]:
pkt = quick_draft(shop, unit_id, title, n=len(w.k) + 1, send=ready_dept, **over)
w.k[key] = int(pkt["id"])
w.every.add(int(pkt["id"]))
return pkt
@pytest.fixture(scope="module")
def world(tmp_path_factory: pytest.TempPathFactory) -> Iterator[SearchWorld]:
with module_shop(tmp_path_factory.mktemp("search")) as shop:
s = shop.settings
w = SearchWorld(shop)
p1 = make_project(s, "L489", "BPX separator", customer="BPX Energy", po="PO-4471")
add_alias(s, p1, "OLD489X")
u52, u53, u54 = (make_unit(s, p1, lot) for lot in ("052", "053", "054"))
p2 = make_project(s, "L512", "Pump skid", customer="Vortex Oil", po="PO-9")
u2 = make_unit(s, p2, "001")
make(shop, w, "title", u52, "Inlet spool", drawing="D-48810", rev="B")
make(shop, w, "op", u53, "Bravo spool", op="HYDRO-TEST")
make(shop, w, "wps", u53, "Charlie spool", wps="WPS-77X")
make(shop, w, "cust", u54, "Delta spool", customer="Acme Gas")
make(
shop,
w,
"wo",
u54,
"Echo spool",
work_orders=[{"wo_base": "5ZZQ", "lot": "007", "split": "0", "sub": "0", "qty": 1, "target_hrs": 1}],
)
make(
shop,
w,
"heat",
u52,
"Foxtrot spool",
bom=[
{"item": 1, "qty": 1, "part_id": "FLG-9876", "description": "Weld neck flange"},
{"item": 2, "qty": 1, "part_id": "FLG-9877", "description": "Blind flange"},
],
)
make(
shop,
w,
"mat",
u53,
"Golf spool",
bom=[
{"item": 1, "qty": 2, "part_id": "GSK-1", "description": "Spiral wound gasket", "material": "A182-F316"}
],
)
make(shop, w, "p2", u2, "Hotel spool")
make(shop, w, "asg", u2, "India spool", ready_dept="03")
make(shop, w, "co", u52, "Juliet spool")
make(shop, w, "dept", u2, "Kilo spool", ready_dept="06")
make(shop, w, "flag", u54, "Lima spool")
make(shop, w, "long", u54, LONG)
# the assigned welder and the stamp
asg = ok(shop("sup.wes").get(f"/api/packets/{w.k['asg']}"))
ok(
step(
shop("sup.wes"), asg, "assign", station_id=shop.stall("03", "Stall 2"), welder_id=shop.uid("weld.andy")
)
)
run(s, "UPDATE packets SET welder_stamp = 'A7Q' WHERE id = %s", (w.k["asg"],))
run(s, "UPDATE users SET welder_stamp = 'A7Q' WHERE sam_account = 'weld.andy'")
# two BOM lines with one heat and two welds with one stamp: one packet, never two rows
run(s, "UPDATE bom_lines SET heat = 'HX44921' WHERE packet_id = %s", (w.k["heat"],))
for no in (1, 2):
run(
s,
"INSERT INTO welds (packet_id, weld_no, items, type, stamp, welder_id) VALUES (%s, %s, '{1}',"
" 'Fillet', 'ZQ9', (SELECT id FROM users WHERE sam_account = 'lead.lou'))",
(w.k["heat"], no),
)
# a change order with an ECN
run(
s,
"INSERT INTO change_orders (packet_id, co_no, origin, review, minor, ecn, reason)"
" VALUES (%s, 47, 'manual', 'approved', true, 'ECN-7781', 'Nozzle moved')",
(w.k["co"],),
)
# a VISUAL draft with a flag kind
run(
s,
"UPDATE packets SET source_kind = 'visual', flags = '{rev_mismatch}', drawing_state = 'rev_mismatch'"
" WHERE id = %s",
(w.k["flag"],),
)
yield w
def found(w: SearchWorld, q: str, sam: str = "sup.wes") -> list[int]:
r = w.shop(sam).get("/api/packets", params={"q": q})
assert r.status_code == 200, r.text
return [int(c["id"]) for c in r.json()["packets"]]
def hits(w: SearchWorld, q: str) -> set[int]:
got = found(w, q)
assert len(got) == len(set(got)), ("duplicate rows", q, got)
return set(got)
P1 = ("title", "op", "wps", "cust", "wo", "heat", "mat", "co", "flag", "long")
P2 = ("p2", "asg", "dept")
DRAFTS = tuple(k for k in (*P1, *P2) if k not in ("asg", "dept"))
@pytest.mark.parametrize(
("q", "keys"),
[
("L512", P2), # project number
("pump", P2), # project name
("L512001", P2), # unit number
("L5121", P2), # unit number with the lot's zeros stripped (L512 || 1)
("L48952", ("title", "heat", "co")), # the same for lot 052
("lot 52", ("title", "heat", "co")), # lot 052 by its number
("HYDRO-TEST", ("op",)), # operation
("D-48810", ("title",)), # drawing
("D-48810 rev B", ("title",)), # drawing and rev
("WPS-77X", ("wps",)), # WPS
("acme", ("cust",)), # packet customer
("vortex", P2), # project customer
("4471", P1), # project PO
("OLD489X", P1), # project alias
("5zzq", ("wo",)), # work order base
("5ZZQ/007", ("wo",)), # work order and lot
("hx449", ("heat",)), # BOM heat
("FLG-9876", ("heat",)), # BOM part id
("spiral", ("mat",)), # BOM description
("A182-F316", ("mat",)), # BOM material
("andy", ("asg",)), # assigned welder's name
("A7Q", ("asg",)), # assigned welder's stamp
("ZQ9", ("heat",)), # weld stamp
("lou", ("heat",)), # the welder named on a weld
("CO-47", ("co",)), # change order number
("ECN-7781", ("co",)), # ECN
("stall", ("asg",)), # station name
("bldg", ("dept",)), # department name
("sent", ("asg",)), # status label: assigned is "Sent to station"
("rev_mismatch", ("flag",)), # flag kind
("flagged", DRAFTS), # every flagged draft (hand drafts and the flagged VISUAL draft)
("inlet", ("title",)), # title
("INLET", ("title",)), # case insensitive
],
)
def test_search_field(world: SearchWorld, q: str, keys: tuple[str, ...]) -> None:
assert hits(world, q) == world.ids(*keys)
def test_lot_and_rev_alone_match_everything(world: SearchWorld) -> None:
assert hits(world, "lot") == world.every
assert hits(world, "rev") == world.every
assert hits(world, "lot 052") == world.ids("title", "heat", "co")
def test_several_words_narrow(world: SearchWorld) -> None:
assert hits(world, "L489") == world.ids(*P1)
assert hits(world, "L489 spool") == world.ids(*(k for k in P1 if k != "long"))
assert hits(world, "L489 52") == world.ids("title", "heat", "co")
assert hits(world, "L489 52 inlet") == world.ids("title")
assert hits(world, "L512 HYDRO-TEST") == set()
assert hits(world, " inlet L489 ") == world.ids("title")
def test_no_duplicates_from_child_rows(world: SearchWorld) -> None:
for q in ("hx449", "ZQ9", "FLG", "lou", "flange"):
got = found(world, q)
assert got == [world.k["heat"]], (q, got)
def test_long_word_is_cut_at_60(world: SearchWorld) -> None:
assert hits(world, LONG) == world.ids("long")
assert hits(world, LONG + "ZZZZ") == world.ids("long") # cut to the first 60 characters
assert hits(world, "Q" * 61) == world.ids("long")
def test_only_eight_words_count(world: SearchWorld) -> None:
assert hits(world, " ".join(["inlet"] * 8)) == world.ids("title")
assert hits(world, " ".join(["lot"] * 8 + ["inlet"])) == world.every
def test_like_wildcards_are_literal(world: SearchWorld) -> None:
assert hits(world, "%") == set()
assert hits(world, "_") == world.ids("flag") # only the flag kind has an underscore