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