Files
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

189 lines
7.4 KiB
Python

"""Phase 5 station clock: every value is the server's. START starts it, the clock endpoint pauses and resumes it,
PULL_BACK and SUBMIT fold the running time in, the live elapsed grows without a write, and no request field sets
it. Time is moved by updating clock_running_since by SQL, so the tests do not wait."""
from __future__ import annotations
import time
from collections.abc import Iterator
from datetime import datetime
from typing import Any
import pytest
from fakedir import FakeDirectory
from pkthelpers import step
from pshelpers import needs_pg
from shophelpers import (
SIGN,
Shop,
finish_stage,
fresh,
im,
in_progress,
in_review,
last_log,
ok,
one,
open_shop,
ready_packet,
refused,
set_running_since,
start,
to_station,
)
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 stored(shop: Shop, pid: int) -> tuple[int, bool]:
row = one(
shop.settings,
"SELECT clock_elapsed_ms, clock_running_since IS NOT NULL FROM packet_stages"
" WHERE packet_id = %s AND closed_at IS NULL",
(pid,),
)
return int(row[0]), bool(row[1])
def clock_op(shop: Shop, pkt: dict[str, Any], op: str, by: str = "weld.andy", **extra: Any) -> Any:
return shop(by).post(f"/api/packets/{pkt['id']}/stage/clock", json={"op": op, **extra})
def ts(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def test_start_pause_resume_pull_back(shop: Shop) -> None:
pkt = to_station(shop, ready_packet(shop))
c = pkt["stage"]["clock"]
assert (c["running"], c["stored_ms"], c["elapsed_ms"], c["running_since"], c["started_at"]) == (
False,
0,
0,
None,
None,
)
pkt = start(shop, pkt)
c = pkt["stage"]["clock"]
assert c["running"] is True and c["stored_ms"] == 0 and c["running_since"] and c["started_at"]
started_at = c["started_at"]
# pause after 90 s of running
set_running_since(shop.settings, pkt["id"], 90)
out = ok(clock_op(shop, pkt, "pause"))
c = out["stage"]["clock"]
assert c["running"] is False and c["running_since"] is None
assert 90_000 <= c["stored_ms"] < 95_000 and c["elapsed_ms"] == c["stored_ms"]
assert stored(shop, pkt["id"]) == (c["stored_ms"], False)
assert last_log(shop("weld.andy"), pkt["id"])["action"] == "Paused"
paused_at = c["stored_ms"]
# resume, run 30 s, then the supervisor pulls it back: the running time is folded in
out = ok(clock_op(shop, out, "resume"))
assert out["stage"]["clock"]["running"] is True and out["stage"]["clock"]["started_at"] == started_at
assert last_log(shop("weld.andy"), pkt["id"])["action"] == "Resumed"
set_running_since(shop.settings, pkt["id"], 30)
out = ok(step(shop("sup.wes"), out, "pull_back"))
c = out["stage"]["clock"]
assert c["running"] is False and paused_at + 30_000 <= c["stored_ms"] < paused_at + 35_000
assert stored(shop, pkt["id"])[1] is False
# assigned and started again: it carries on from the stored value
out = start(shop, to_station(shop, out))
assert out["stage"]["clock"]["stored_ms"] == c["stored_ms"] and out["stage"]["clock"]["running"] is True
assert out["stage"]["clock"]["started_at"] == started_at
def test_live_elapsed_grows_without_a_write(shop: Shop) -> None:
pkt = in_progress(shop)
set_running_since(shop.settings, pkt["id"], 10)
a = fresh(shop("weld.andy"), pkt["id"])
time.sleep(1.1)
b = fresh(shop("weld.andy"), pkt["id"])
assert a["version"] == b["version"]
assert a["stage"]["clock"]["stored_ms"] == b["stage"]["clock"]["stored_ms"] == 0
assert 10_000 <= a["stage"]["clock"]["elapsed_ms"] < b["stage"]["clock"]["elapsed_ms"]
assert b["stage"]["clock"]["elapsed_ms"] - a["stage"]["clock"]["elapsed_ms"] >= 1_000
# elapsed = stored + (server_now - running_since), from the server's own clock
since = ts(b["stage"]["clock"]["running_since"])
now = ts(b["server_now"])
want = (now - since).total_seconds() * 1000
assert abs(b["stage"]["clock"]["elapsed_ms"] - want) < 1_000
def test_submit_stops_the_clock(shop: Shop) -> None:
pkt = finish_stage(shop, in_progress(shop))
set_running_since(shop.settings, pkt["id"], 3 * 3600)
out = ok(step(shop("weld.andy"), pkt, "submit", sign=SIGN))
c = out["stage"]["clock"]
assert c["running"] is False and 3 * 3_600_000 <= c["stored_ms"] < 3 * 3_600_000 + 10_000
assert last_log(shop("weld.andy"), pkt["id"])["note"] in ("1 weld · 3.0 h", "1 welds · 3.0 h")
def test_clock_state_conflicts(shop: Shop) -> None:
pkt = to_station(shop, ready_packet(shop))
# never started: pause and resume are refused
r = refused(clock_op(shop, pkt, "pause"), 409, "clock_state")
assert r["current"] == {"running": False, "started": False}
refused(clock_op(shop, pkt, "resume"), 409, "clock_state")
pkt = start(shop, pkt)
r = refused(clock_op(shop, pkt, "start"), 409, "clock_state")
assert r["current"]["running"] is True
refused(clock_op(shop, pkt, "resume"), 409, "clock_state")
ok(clock_op(shop, pkt, "pause"))
refused(clock_op(shop, pkt, "pause"), 409, "clock_state")
assert clock_op(shop, pkt, "stop").status_code == 422
# who: Q.C. and the warehouse may not touch it
for sam in ("qc.quinn", "wh.wade", "clerk.clara", "multi.mia"):
refused(clock_op(shop, pkt, "resume", by=sam), 403, "forbidden")
def test_no_field_sets_elapsed(shop: Shop) -> None:
pkt = in_progress(shop)
set_running_since(shop.settings, pkt["id"], 60)
big = 999_999_999
# the clock endpoint takes only op
r = clock_op(shop, pkt, "pause", elapsed_ms=big, clock_elapsed_ms=big, stored_ms=big)
assert r.status_code in (200, 422), r.text
if r.status_code == 422:
ok(clock_op(shop, pkt, "pause"))
ms, running = stored(shop, pkt["id"])
assert 60_000 <= ms < 70_000 and running is False
# the stage fields refuse or ignore clock columns, in both body shapes
url = f"/api/packets/{pkt['id']}/stage"
for body in (
{"set": {"clock_elapsed_ms": big, "clock_running_since": None}, "base": {}},
{"set": {"filler": "ER70S-6", "clock_elapsed_ms": big}, "base": {}},
):
r = shop("weld.andy").patch(url, json=body)
assert r.status_code in (200, 422), r.text
rv = fresh(shop("weld.andy"), pkt["id"])["stage"]["row_version"]
r = shop("weld.andy").patch(url, json={"clock_elapsed_ms": big, "notes": "x"}, headers=im(rv))
assert r.status_code in (200, 422), r.text
assert stored(shop, pkt["id"])[0] == ms
# the transitions carry no clock either
r = step(shop("sup.wes"), fresh(shop("sup.wes"), pkt["id"]), "pull_back", elapsed_ms=big, clock_elapsed_ms=big)
assert r.status_code in (200, 422), r.text
assert stored(shop, pkt["id"])[0] == ms
# and the read model's value is the stored one
assert fresh(shop("sup.wes"), pkt["id"])["stage"]["clock"]["elapsed_ms"] == ms
def test_closed_stage_clock(shop: Shop) -> None:
pkt = in_review(shop)
out = ok(step(shop("sup.wes"), pkt, "route_from_review", dept="09", sign=SIGN))
old = out["stages"][-1]
assert old["clock"]["running"] is False and old["clock"]["elapsed_ms"] == old["clock"]["stored_ms"]
new = out["stage"]["clock"]
assert (new["running"], new["stored_ms"], new["elapsed_ms"]) == (False, 0, 0)