Files
PacketStation/tests/server/shophelpers.py
T
spencermandClaude Fable 5.1 5a4174e0c2
ci / frontend (push) Successful in 34s
ci / server (push) Successful in 7m15s
ci / image (push) Successful in 39s
ci / e2e (push) Successful in 2m4s
Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.

Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 22:46:56 -06:00

393 lines
14 KiB
Python

"""Helpers for the phase 5 shop tests: one signed-in client per user, a packet brought to `ready` in 03 through
the warehouse, and the shop steps (assign, start, finish the stage, submit, review).
The packet: a hand draft in 03 on a fresh unit, two work orders (target 2 h and 2.5 h), three BOM lines, a
two-page PDF drawing, heat map answered, sent to the warehouse, every line pulled with a heat by wh.wade, released.
"""
from __future__ import annotations
import secrets
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any
from fakedir import FAKE_PASSWORD, FakeDirectory
from fastapi.testclient import TestClient
from pkthelpers import phase2_client, seed_users, step, tiny_pdf, upload
from pshelpers import ORIGIN_HEADERS, app_conn, sign_in
from psycopg.types.json import Jsonb
from packetstation.settings import Settings
SIGN = {"factor": "password", "secret": FAKE_PASSWORD}
IT = "Ticket 5501: shop lead is out today"
STALE = "Someone else already took this step."
USERS = (
"admin.ann",
"clerk.clara",
"clerk.leo",
"wh.wade",
"qc.quinn",
"pm.paula",
"weld.andy",
"lead.lou",
"sup.wes",
"multi.mia",
"both.bo",
"dup.dave",
)
WOS = [
{"wo_base": "L670001", "lot": "001", "split": "0", "sub": "0", "qty": 2, "target_hrs": 2},
{"wo_base": "L670002", "lot": "001", "split": "0", "sub": "0", "qty": 1, "target_hrs": 2.5},
]
BOM3 = [
{"item": 1, "qty": 1, "part_id": "PIPE-4", "description": "4in pipe SCH40", "material": "A106-B"},
{"item": 2, "qty": 2, "part_id": "GSKT-4", "description": "4in gasket"},
{"item": 3, "qty": 4, "part_id": "STUD-58", "description": "5/8 stud bolt"},
]
WELD_KEYS = ("dwg", "heat", "wps", "fit", "pre", "vt", "stamp", "clean", "qty")
PLAIN_KEYS = ("dwg", "prev", "mat", "work", "insp", "qty")
MSG_CHECKLIST = "Finish the checklist first."
MSG_WELDS = "Record at least one weld first."
MSG_FILLER = "Enter the filler metal first."
MSG_QC = "Confirm the book and the spool, and give the location."
MSG_OTHER_DEPT = "Choose a different department."
# ---------------------------------------------------------------- clients
@dataclass
class Shop:
"""One app, one signed-in TestClient per user (separate cookie jars)."""
main: TestClient
settings: Settings
clients: dict[str, TestClient] = field(default_factory=dict)
def __call__(self, sam: str) -> TestClient:
c = self.clients.get(sam)
if c is None:
c = self.fresh(sam)
self.clients[sam] = c
return c
def fresh(self, sam: str) -> TestClient:
"""A new client with its own session (two sessions of one user, or a thread of its own)."""
c = TestClient(self.main.app, headers=ORIGIN_HEADERS)
r = sign_in(c, sam)
assert r.status_code == 200, r.text
return c
def uid(self, sam: str) -> str:
return user_id(self.settings, sam)
def stall(self, dept: str = "03", name: str = "Stall 1") -> int:
return station_id(self.settings, dept, name)
def open_shop(settings: Settings, fake_dir: FakeDirectory) -> Iterator[Shop]:
"""The body of a `shop` fixture: the app, every test user signed in once (their users rows exist)."""
with phase2_client(settings, fake_dir) as client:
seed_users(client, USERS)
yield Shop(client, settings)
# ---------------------------------------------------------------- SQL
def one(settings: Settings, sql: str, params: Any = ()) -> Any:
with app_conn(settings) as conn:
return conn.execute(sql, params).fetchone()
def all_rows(settings: Settings, sql: str, params: Any = ()) -> list[Any]:
with app_conn(settings) as conn:
return list(conn.execute(sql, params).fetchall())
def run(settings: Settings, sql: str, params: Any = ()) -> None:
with app_conn(settings) as conn:
conn.execute(sql, params)
def user_id(settings: Settings, sam: str) -> str:
row = one(settings, "SELECT id FROM users WHERE sam_account = %s", (sam,))
assert row is not None, sam
return str(row[0])
def station_id(settings: Settings, dept: str = "03", name: str = "Stall 1") -> int:
row = one(settings, "SELECT id FROM stations WHERE dept_code = %s AND name = %s", (dept, name))
assert row is not None, (dept, name)
return int(row[0])
def set_stamp(settings: Settings, sam: str, stamp: str | None) -> None:
run(settings, "UPDATE users SET welder_stamp = %s WHERE sam_account = %s", (stamp, sam))
def set_limits(settings: Settings, sam: str, station_ids: list[int]) -> None:
"""Replace a user's station limits (an empty list removes them)."""
with app_conn(settings) as conn:
uid = conn.execute("SELECT id FROM users WHERE sam_account = %s", (sam,)).fetchone()[0]
conn.execute("DELETE FROM user_station_limits WHERE user_id = %s", (uid,))
for sid in station_ids:
conn.execute("INSERT INTO user_station_limits (user_id, station_id) VALUES (%s, %s)", (uid, sid))
def make_unit(settings: Settings, number: str | None = None, lot: str = "001") -> tuple[int, int]:
"""A project with one unit by SQL. Returns (project_id, unit_id)."""
number = number or "L5" + secrets.token_hex(3).upper()
with app_conn(settings) as conn:
pid = conn.execute(
"INSERT INTO projects (number, name, customer, po) VALUES (%s, 'Shop project', 'Acme Refining', 'PO-55')"
" RETURNING id",
(number,),
).fetchone()[0]
uid = conn.execute(
"INSERT INTO units (project_id, lot, name, status) VALUES (%s, %s, 'Unit one', 'open') RETURNING id",
(pid, lot),
).fetchone()[0]
return int(pid), int(uid)
def set_running_since(settings: Settings, packet_id: int, seconds_ago: float) -> None:
"""Move the open stage's running clock back in time, as if it had run that long."""
run(
settings,
"UPDATE packet_stages SET clock_running_since = now() - make_interval(secs => %s)"
" WHERE packet_id = %s AND closed_at IS NULL AND clock_running_since IS NOT NULL",
(seconds_ago, packet_id),
)
def set_templates(settings: Settings, value: Any) -> None:
run(
settings,
"INSERT INTO settings (key, value, version, updated_at) VALUES ('checklist_templates', %s, 1, now())"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, version = settings.version + 1",
(Jsonb(value),),
)
# ---------------------------------------------------------------- reads
def fresh(c: TestClient, pid: int) -> dict[str, Any]:
r = c.get(f"/api/packets/{pid}")
assert r.status_code == 200, r.text
out: dict[str, Any] = r.json()
return out
def log_rows(c: TestClient, pid: int) -> list[dict[str, Any]]:
r = c.get(f"/api/packets/{pid}/log")
assert r.status_code == 200, r.text
out: list[dict[str, Any]] = r.json()
return out
def last_log(c: TestClient, pid: int) -> dict[str, Any]:
return log_rows(c, pid)[0]
def find_log(c: TestClient, pid: int, action: str) -> dict[str, Any]:
"""The newest log row with this action text."""
rows = [r for r in log_rows(c, pid) if r["action"] == action]
assert rows, (action, [r["action"] for r in log_rows(c, pid)])
return rows[0]
def ok(r: Any) -> dict[str, Any]:
assert r.status_code == 200, r.text
out: dict[str, Any] = r.json()
return out
def refused(r: Any, status: int, code: str, detail: str | None = None) -> dict[str, Any]:
assert r.status_code == status, r.text
body: dict[str, Any] = r.json()
assert body["code"] == code, body
if detail is not None:
assert body["detail"] == detail, body
return body
def weld_of(pkt: dict[str, Any], no: int) -> dict[str, Any]:
return next(w for w in pkt["welds"] if w["weld_no"] == no)
def tag_of(pkt: dict[str, Any], no: int) -> dict[str, Any]:
return next(t for t in pkt["heat_tags"] if t["tag_no"] == no)
def line_of(pkt: dict[str, Any], item: int) -> dict[str, Any]:
return next(b for b in pkt["bom"] if b["item"] == item)
def im(v: int) -> dict[str, str]:
return {"If-Match": str(v)}
# ---------------------------------------------------------------- the packet
def draft(
shop: Shop,
unit_id: int | None = None,
*,
title: str = "Spool 7 outlet",
drawing: str = "D-5001",
rev: str = "B",
bom: list[dict[str, Any]] | None = None,
wos: list[dict[str, Any]] | None = None,
dept: str = "03",
pages: int = 2,
) -> dict[str, Any]:
"""A hand draft by clerk.clara with the heat map answered and a PDF drawing of `pages` pages."""
if unit_id is None:
_, unit_id = make_unit(shop.settings)
c = shop("clerk.clara")
body = {
"unit_id": unit_id,
"dept_code": dept,
"op": "WELD-PART",
"title": title,
"customer": "Acme Refining",
"drawing": drawing,
"rev": rev,
"wps": "WPS-11",
"work_orders": WOS if wos is None else wos,
"bom": BOM3 if bom is None else bom,
}
r = c.post("/api/packets", json=body)
assert r.status_code == 201, r.text
pid = r.json()["id"]
assert c.patch(f"/api/packets/{pid}", json={"set": {"heat_map": True}, "base": {}}).status_code == 200
a = upload(c, tiny_pdf(pages))
assert a.status_code == 201, a.text
r = c.post(f"/api/packets/{pid}/pages", json={"asset_id": a.json()["id"], "type": "drawing", "sign": SIGN})
return ok(r)
def to_warehouse(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
out = ok(step(shop("clerk.clara"), pkt, "send_to_warehouse"))
assert out["status"] == "warehouse"
return out
def pull_all(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
"""wh.wade ticks every line pulled and gives it a heat."""
c = shop("wh.wade")
for b in list(pkt["bom"]):
item = b["item"]
cur = line_of(pkt, item)
pkt = ok(c.patch(f"/api/packets/{pkt['id']}/bom/{item}", json={"pulled": True}, headers=im(cur["row_version"])))
cur = line_of(pkt, item)
pkt = ok(
c.patch(
f"/api/packets/{pkt['id']}/bom/{item}", json={"heat": f"HT-{item}0"}, headers=im(cur["row_version"])
)
)
return pkt
def release(shop: Shop, pkt: dict[str, Any]) -> dict[str, Any]:
out = ok(step(shop("wh.wade"), pkt, "wh_release"))
assert out["status"] == "ready" and out["dept"]["code"] == "03", out["status"]
return out
def ready_packet(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
"""Draft, warehouse, every line pulled with a heat, released: `ready` in 03 with an open stage."""
pkt = to_warehouse(shop, draft(shop, unit_id, **kw))
return release(shop, pull_all(shop, pkt))
def ready_skipped(shop: Shop, unit_id: int | None = None, dept: str = "03", **kw: Any) -> dict[str, Any]:
"""A draft sent straight to a department (wh_skipped true)."""
pkt = draft(shop, unit_id, **kw)
out = ok(step(shop("clerk.clara"), pkt, "release_to_shop", dept=dept))
assert out["status"] == "ready" and out["wh_skipped"] is True
return out
def to_station(
shop: Shop,
pkt: dict[str, Any],
*,
by: str = "sup.wes",
welder: str = "weld.andy",
dept: str = "03",
station: str = "Stall 1",
**kw: Any,
) -> dict[str, Any]:
"""ASSIGN the packet to a station and a welder."""
out = ok(step(shop(by), pkt, "assign", station_id=shop.stall(dept, station), welder_id=shop.uid(welder), **kw))
assert out["status"] == "assigned"
return out
def start(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy") -> dict[str, Any]:
out = ok(step(shop(by), pkt, "start"))
assert out["status"] == "in_progress"
return out
def add_weld(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", **over: Any) -> dict[str, Any]:
body: dict[str, Any] = {"page": 1, "fx": 0.5, "fy": 0.5, "items": [1, 2], "type": "Fillet", "process": "GMAW"}
body.update(over)
return ok(shop(by).post(f"/api/packets/{pkt['id']}/welds", json=body))
def tick_all(
shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", keys: tuple[str, ...] = WELD_KEYS
) -> dict[str, Any]:
for k in keys:
pkt = ok(shop(by).put(f"/api/packets/{pkt['id']}/stage/checklist/{k}", json={"done": True}))
return pkt
def set_filler(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", filler: str = "ER70S-6") -> dict[str, Any]:
return ok(shop(by).patch(f"/api/packets/{pkt['id']}/stage", json={"set": {"filler": filler}, "base": {}}))
def finish_stage(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy", welds: int = 1) -> dict[str, Any]:
"""Every checklist key ticked, `welds` welds marked, the filler set: SUBMIT can pass."""
keys = WELD_KEYS if pkt["dept"]["weld"] else PLAIN_KEYS
pkt = tick_all(shop, pkt, by, keys)
for _ in range(welds):
pkt = add_weld(shop, pkt, by)
return set_filler(shop, pkt, by)
def submit(shop: Shop, pkt: dict[str, Any], by: str = "weld.andy") -> dict[str, Any]:
out = ok(step(shop(by), pkt, "submit", sign=SIGN))
assert out["status"] == "review"
return out
def in_progress(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
return start(shop, to_station(shop, ready_packet(shop, unit_id, **kw)))
def in_review(shop: Shop, unit_id: int | None = None, **kw: Any) -> dict[str, Any]:
pkt = in_progress(shop, unit_id, **kw)
return submit(shop, finish_stage(shop, pkt))
def issue_co(shop: Shop, pkt: dict[str, Any], required: list[str], **over: Any) -> dict[str, Any]:
"""A manual change order by clerk.clara (approved at once, holds until every required department verifies)."""
body = {"reason": "Nozzle moved 2 in on rev C", "required_depts": required, "sign": SIGN, **over}
return ok(shop("clerk.clara").post(f"/api/packets/{pkt['id']}/change-orders", json=body))
def verify(shop: Shop, pkt: dict[str, Any], no: int, dept: str, by: str, **over: Any) -> Any:
body = {"dept": dept, "note": "Reviewed with the crew", "sign": SIGN, **over}
return shop(by).post(f"/api/packets/{pkt['id']}/change-orders/{no}/verify", json=body)