189 lines
7.4 KiB
Python
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)
|