2f0a5aaf1e
Refs #124 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
867 lines
36 KiB
Python
867 lines
36 KiB
Python
"""Integration tests for /api/production-runs.
|
||
|
||
The point of these tables is that a production outlives the page it was started
|
||
from, so the tests lean on what must survive: the deadline, the cycle count, the
|
||
trace of what happened, and the rule that a station has one production at a time.
|
||
"""
|
||
from datetime import datetime, timedelta
|
||
|
||
import pytest
|
||
from httpx import AsyncClient
|
||
from sqlalchemy import select
|
||
|
||
from src.backend.models.orm.production import ProductionRun
|
||
from src.backend.models.orm.recipe import Recipe
|
||
from src.backend.models.orm.station import Station
|
||
from src.backend.models.orm.task import RecipeTask
|
||
from src.backend.services import auth_service, production_service
|
||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||
|
||
|
||
async def _station(db_session, user_id: int, code: str = "ST-PROD") -> Station:
|
||
station = Station(code=code, name=f"Stazione {code}", active=True, created_by=user_id)
|
||
db_session.add(station)
|
||
await db_session.commit()
|
||
await db_session.refresh(station)
|
||
return station
|
||
|
||
|
||
async def _recipe_with_interval(db_session, user_id: int, minutes, code="REC-PROD") -> Recipe:
|
||
recipe = await create_test_recipe(db_session, user_id=user_id, code=code)
|
||
recipe.measurement_interval_minutes = minutes
|
||
await db_session.commit()
|
||
await db_session.refresh(recipe)
|
||
return recipe
|
||
|
||
|
||
async def _supervisor(db_session, username="capoturno", password="TurnoPwd1"):
|
||
user = await auth_service.create_user(
|
||
db_session,
|
||
username=username,
|
||
password=password,
|
||
display_name="Capo Turno",
|
||
roles=["Supervisor"],
|
||
)
|
||
await db_session.commit()
|
||
return user, password
|
||
|
||
|
||
async def _advance_clock(db_session, run_id: int, delta: timedelta) -> None:
|
||
"""Simulate wall-clock time passing, without sleeping in a test.
|
||
|
||
Winding every instant stored on the run back by D is indistinguishable from D
|
||
having elapsed. Moving only one of them would not be time passing - it would be
|
||
rewriting history, and would measure the wrong thing.
|
||
"""
|
||
run = (await db_session.execute(
|
||
select(ProductionRun).where(ProductionRun.id == run_id)
|
||
)).scalar_one()
|
||
run.started_at = run.started_at - delta
|
||
if run.next_measurement_at is not None:
|
||
run.next_measurement_at = run.next_measurement_at - delta
|
||
if run.paused_at is not None:
|
||
run.paused_at = run.paused_at - delta
|
||
await db_session.commit()
|
||
|
||
|
||
async def _only_task(db_session, version_id: int):
|
||
"""The single task the fixture recipe comes with - a measurement one."""
|
||
row = await db_session.execute(
|
||
select(RecipeTask)
|
||
.where(RecipeTask.version_id == version_id)
|
||
.order_by(RecipeTask.order_index)
|
||
)
|
||
return row.scalars().first()
|
||
|
||
|
||
async def _add_task(
|
||
db_session, version_id: int, order_index: int, title: str, task_type: str = "measure",
|
||
) -> RecipeTask:
|
||
"""A further task in the sequence, of a declared type."""
|
||
task = RecipeTask(
|
||
version_id=version_id, order_index=order_index, title=title, task_type=task_type,
|
||
)
|
||
db_session.add(task)
|
||
await db_session.commit()
|
||
await db_session.refresh(task)
|
||
return task
|
||
|
||
|
||
async def _open(client, user, station, recipe, **extra):
|
||
return await client.post(
|
||
"/api/production-runs",
|
||
headers=auth_headers(user),
|
||
json={"station_code": station.code, "recipe_id": recipe.id, **extra},
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Opening
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_open_run_requires_auth(client: AsyncClient):
|
||
resp = await client.post("/api/production-runs", json={"station_code": "X", "recipe_id": 1})
|
||
assert resp.status_code == 401
|
||
|
||
|
||
async def test_open_run_starts_the_countdown(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id)
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, minutes=30)
|
||
|
||
resp = await _open(client, measurement_tec_user, station, recipe)
|
||
assert resp.status_code == 201, resp.text
|
||
body = resp.json()
|
||
assert body["status"] == "running"
|
||
assert body["cycle_count"] == 0
|
||
assert body["measurement_interval_minutes"] == 30
|
||
# About half an hour out, allowing for the round trip.
|
||
assert 29 * 60 <= body["seconds_to_next_measurement"] <= 30 * 60
|
||
assert body["overdue"] is False
|
||
|
||
|
||
async def test_open_run_without_interval_has_no_deadline(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-NOINT")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, None, code="REC-NOINT")
|
||
|
||
body = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||
assert body["next_measurement_at"] is None
|
||
assert body["seconds_to_next_measurement"] is None
|
||
assert body["overdue"] is False
|
||
|
||
|
||
async def test_open_run_snapshots_the_interval(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""Editing the recipe mid-production must not move a running deadline."""
|
||
station = await _station(db_session, admin_user.id, code="ST-SNAP")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 20, code="REC-SNAP")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
|
||
recipe.measurement_interval_minutes = 90
|
||
await db_session.commit()
|
||
|
||
resp = await client.get(
|
||
f"/api/production-runs/{run_id}", headers=auth_headers(measurement_tec_user),
|
||
)
|
||
assert resp.json()["measurement_interval_minutes"] == 20
|
||
|
||
|
||
async def test_station_cannot_have_two_open_runs(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-ONE")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ONE")
|
||
first = await _open(client, measurement_tec_user, station, recipe)
|
||
assert first.status_code == 201
|
||
|
||
second = await _open(client, measurement_tec_user, station, recipe)
|
||
assert second.status_code == 409
|
||
|
||
|
||
async def test_open_run_rejects_unknown_station(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-NOSTAT")
|
||
resp = await client.post(
|
||
"/api/production-runs",
|
||
headers=auth_headers(measurement_tec_user),
|
||
json={"station_code": "ST-GHOST", "recipe_id": recipe.id},
|
||
)
|
||
assert resp.status_code == 404
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Role and station-assignment gate (gitea#24)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_open_run_requires_measurement_tec_or_supervisor(
|
||
client: AsyncClient, maker_user, admin_user, db_session,
|
||
):
|
||
"""A Maker with no floor role must not be able to start a production."""
|
||
station = await _station(db_session, admin_user.id, code="ST-ROLE")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ROLE")
|
||
|
||
resp = await _open(client, maker_user, station, recipe)
|
||
assert resp.status_code == 403
|
||
|
||
|
||
async def test_open_run_rejects_recipe_not_assigned_to_station(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
from src.backend.services import station_service
|
||
|
||
station = await _station(db_session, admin_user.id, code="ST-ASSIGN")
|
||
assigned_recipe = await _recipe_with_interval(
|
||
db_session, admin_user.id, 15, code="REC-ASSIGNED"
|
||
)
|
||
other_recipe = await _recipe_with_interval(
|
||
db_session, admin_user.id, 15, code="REC-OTHER"
|
||
)
|
||
await station_service.assign_recipe(db_session, station.id, assigned_recipe.id, admin_user)
|
||
await db_session.commit()
|
||
|
||
resp = await _open(client, measurement_tec_user, station, other_recipe)
|
||
assert resp.status_code == 409
|
||
|
||
|
||
async def test_open_run_allows_recipe_assigned_to_station(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
from src.backend.services import station_service
|
||
|
||
station = await _station(db_session, admin_user.id, code="ST-ASSIGNOK")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ASSIGNOK")
|
||
await station_service.assign_recipe(db_session, station.id, recipe.id, admin_user)
|
||
await db_session.commit()
|
||
|
||
resp = await _open(client, measurement_tec_user, station, recipe)
|
||
assert resp.status_code == 201
|
||
|
||
|
||
async def test_open_run_allows_any_recipe_when_station_has_no_assignment(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""An unconfigured station is not restricted - matches the read-side filter
|
||
in station_service.list_station_recipes (empty assignments = not filtering)."""
|
||
station = await _station(db_session, admin_user.id, code="ST-FREE")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-FREE")
|
||
|
||
resp = await _open(client, measurement_tec_user, station, recipe)
|
||
assert resp.status_code == 201
|
||
|
||
|
||
async def test_open_run_rejects_inactive_recipe(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-INACTIVE")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-INACTIVE")
|
||
recipe.active = False
|
||
await db_session.commit()
|
||
|
||
resp = await _open(client, measurement_tec_user, station, recipe)
|
||
assert resp.status_code == 409
|
||
|
||
|
||
async def test_pause_requires_measurement_tec_or_supervisor_actor(
|
||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""The role gate applies to the acting user - separate from the supervisor
|
||
credentials pause already requires in its own body."""
|
||
station = await _station(db_session, admin_user.id, code="ST-ROLEPAUSE")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-ROLEPAUSE")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
supervisor, password = await _supervisor(db_session, username="capo-rolepause")
|
||
|
||
resp = await client.post(
|
||
f"/api/production-runs/{run_id}/pause",
|
||
headers=auth_headers(maker_user),
|
||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||
)
|
||
assert resp.status_code == 403
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Reading the current run - what replaces the in-page state
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_current_run_is_null_when_nothing_is_running(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-IDLE")
|
||
resp = await client.get(
|
||
"/api/production-runs/current",
|
||
params={"station_code": station.code},
|
||
headers=auth_headers(measurement_tec_user),
|
||
)
|
||
assert resp.status_code == 200
|
||
assert resp.json() is None
|
||
|
||
|
||
async def test_current_run_survives_and_keeps_counting_down(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""The deadline is stored, so a fresh page load rejoins the same countdown."""
|
||
station = await _station(db_session, admin_user.id, code="ST-KEEP")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 45, code="REC-KEEP")
|
||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||
|
||
later = await client.get(
|
||
"/api/production-runs/current",
|
||
params={"station_code": station.code},
|
||
headers=auth_headers(measurement_tec_user),
|
||
)
|
||
body = later.json()
|
||
assert body["id"] == opened["id"]
|
||
assert body["next_measurement_at"] == opened["next_measurement_at"]
|
||
assert body["seconds_to_next_measurement"] <= opened["seconds_to_next_measurement"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cycles
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_cycle_restarts_the_interval_and_counts(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-CYC")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-CYC")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
|
||
resp = await client.post(
|
||
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["cycle_count"] == 1
|
||
assert 9 * 60 <= body["seconds_to_next_measurement"] <= 10 * 60
|
||
|
||
|
||
async def test_measurement_tasks_travel_with_the_run(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""Where the measurement is cannot be guessed by the client: the run says so.
|
||
|
||
This is what lets a screen bring the operator back to the measurement when the
|
||
interval expires, whatever page they wandered off to.
|
||
"""
|
||
station = await _station(db_session, admin_user.id, code="ST-SEQ")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-SEQ")
|
||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||
first = await _only_task(db_session, opened["version_id"])
|
||
# A drawing between the two measurements, to prove it is left out.
|
||
await _add_task(db_session, opened["version_id"], 1, "Disegno", "drawing")
|
||
second = await _add_task(db_session, opened["version_id"], 2, "Seconda misura")
|
||
|
||
body = (await client.get(
|
||
"/api/production-runs/current",
|
||
params={"station_code": station.code},
|
||
headers=auth_headers(measurement_tec_user),
|
||
)).json()
|
||
assert body["measurement_task_ids"] == [first.id, second.id]
|
||
|
||
|
||
async def test_cycle_closes_only_on_the_last_measurement_task(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""Restarting at the first would measure three times as often as configured."""
|
||
station = await _station(db_session, admin_user.id, code="ST-MULTI")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-MULTI")
|
||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||
run_id = opened["id"]
|
||
first = await _only_task(db_session, opened["version_id"])
|
||
second = await _add_task(db_session, opened["version_id"], 1, "Seconda misura")
|
||
headers = auth_headers(measurement_tec_user)
|
||
|
||
# Four minutes of the interval have gone by when the first task is finished.
|
||
await _advance_clock(db_session, run_id, timedelta(minutes=4))
|
||
|
||
mid = (await client.post(
|
||
f"/api/production-runs/{run_id}/cycle", headers=headers, json={"task_id": first.id},
|
||
)).json()
|
||
assert mid["cycle_count"] == 0, "il ciclo non e' ancora finito"
|
||
assert 5 * 60 <= mid["seconds_to_next_measurement"] <= 6 * 60, (
|
||
"l'intervallo non deve ripartire a meta' ciclo"
|
||
)
|
||
|
||
end = (await client.post(
|
||
f"/api/production-runs/{run_id}/cycle", headers=headers, json={"task_id": second.id},
|
||
)).json()
|
||
assert end["cycle_count"] == 1
|
||
assert 9 * 60 <= end["seconds_to_next_measurement"] <= 10 * 60
|
||
|
||
events = (await client.get(
|
||
f"/api/production-runs/{run_id}", headers=headers,
|
||
)).json()["events"]
|
||
by_type = {e["event_type"]: e for e in events}
|
||
assert by_type["task_measured"]["task_id"] == first.id
|
||
assert by_type["cycle_completed"]["task_id"] == second.id
|
||
|
||
|
||
async def test_cycle_without_a_task_closes_as_before(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""A caller that knows nothing of the sequence keeps the old behaviour."""
|
||
station = await _station(db_session, admin_user.id, code="ST-PLAIN")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PLAIN")
|
||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||
await _add_task(db_session, opened["version_id"], 1, "Seconda misura")
|
||
|
||
body = (await client.post(
|
||
f"/api/production-runs/{opened['id']}/cycle",
|
||
headers=auth_headers(measurement_tec_user),
|
||
)).json()
|
||
assert body["cycle_count"] == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Rimisura - turning the piece over
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_remeasure_does_not_buy_another_interval(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""A second reading of the same part is not a new cycle."""
|
||
station = await _station(db_session, admin_user.id, code="ST-AGAIN")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-AGAIN")
|
||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||
run_id = opened["id"]
|
||
task = await _only_task(db_session, opened["version_id"])
|
||
headers = auth_headers(measurement_tec_user)
|
||
|
||
await _advance_clock(db_session, run_id, timedelta(minutes=7))
|
||
|
||
body = (await client.post(
|
||
f"/api/production-runs/{run_id}/remeasure", headers=headers,
|
||
json={"task_id": task.id},
|
||
)).json()
|
||
assert body["cycle_count"] == 0
|
||
assert 2 * 60 <= body["seconds_to_next_measurement"] <= 3 * 60, (
|
||
"rigirare il pezzo non deve spostare la scadenza"
|
||
)
|
||
|
||
events = (await client.get(
|
||
f"/api/production-runs/{run_id}", headers=headers,
|
||
)).json()["events"]
|
||
remeasured = [e for e in events if e["event_type"] == "remeasure"]
|
||
assert len(remeasured) == 1
|
||
assert remeasured[0]["task_id"] == task.id
|
||
|
||
|
||
async def test_remeasure_refused_while_the_line_is_stopped(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-AGPAUSE")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-AGPAUSE")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
supervisor, password = await _supervisor(db_session, username="capo-again")
|
||
|
||
await client.post(
|
||
f"/api/production-runs/{run_id}/pause",
|
||
headers=auth_headers(measurement_tec_user),
|
||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||
)
|
||
resp = await client.post(
|
||
f"/api/production-runs/{run_id}/remeasure",
|
||
headers=auth_headers(measurement_tec_user),
|
||
)
|
||
assert resp.status_code == 409
|
||
|
||
|
||
async def test_overdue_run_reports_negative_seconds(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""Past the interval the countdown keeps going the other way, so the delay shows."""
|
||
station = await _station(db_session, admin_user.id, code="ST-LATE")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 5, code="REC-LATE")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
|
||
await _advance_clock(db_session, run_id, timedelta(minutes=8))
|
||
|
||
body = (await client.get(
|
||
"/api/production-runs/current",
|
||
params={"station_code": station.code},
|
||
headers=auth_headers(measurement_tec_user),
|
||
)).json()
|
||
assert body["overdue"] is True
|
||
assert -190 <= body["seconds_to_next_measurement"] <= -170
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fermo linea / ripresa / chiusura
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_pause_requires_supervisor_credentials(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-PAUSE1")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PAUSE1")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
|
||
resp = await client.post(
|
||
f"/api/production-runs/{run_id}/pause",
|
||
headers=auth_headers(measurement_tec_user),
|
||
json={"supervisor_username": "nobody", "supervisor_password": "wrong"},
|
||
)
|
||
assert resp.status_code == 401
|
||
|
||
|
||
async def test_pause_rejects_non_supervisor(
|
||
client: AsyncClient, measurement_tec_user, maker_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-PAUSE2")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PAUSE2")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
|
||
resp = await client.post(
|
||
f"/api/production-runs/{run_id}/pause",
|
||
headers=auth_headers(measurement_tec_user),
|
||
json={
|
||
"supervisor_username": maker_user.username,
|
||
"supervisor_password": "testpassword123",
|
||
},
|
||
)
|
||
assert resp.status_code in (401, 403)
|
||
|
||
|
||
async def test_pause_freezes_the_countdown(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""A fermo linea must not eat the measurement interval."""
|
||
station = await _station(db_session, admin_user.id, code="ST-FREEZE")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 30, code="REC-FREEZE")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
supervisor, password = await _supervisor(db_session, username="capo-freeze")
|
||
|
||
paused = (await client.post(
|
||
f"/api/production-runs/{run_id}/pause",
|
||
headers=auth_headers(measurement_tec_user),
|
||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||
)).json()
|
||
assert paused["status"] == "paused"
|
||
frozen = paused["seconds_to_next_measurement"]
|
||
|
||
# An hour goes by with the line stopped: were the countdown still ticking, the
|
||
# run would now be deeply overdue.
|
||
await _advance_clock(db_session, run_id, timedelta(hours=1))
|
||
|
||
still = (await client.get(
|
||
"/api/production-runs/current",
|
||
params={"station_code": station.code},
|
||
headers=auth_headers(measurement_tec_user),
|
||
)).json()
|
||
assert still["status"] == "paused"
|
||
assert still["seconds_to_next_measurement"] == frozen
|
||
assert still["overdue"] is False
|
||
|
||
|
||
async def test_resume_gives_back_the_time_the_stop_took(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-RESUME")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 30, code="REC-RESUME")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
supervisor, password = await _supervisor(db_session, username="capo-resume")
|
||
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
|
||
|
||
frozen = (await client.post(
|
||
f"/api/production-runs/{run_id}/pause",
|
||
headers=auth_headers(measurement_tec_user), json=creds,
|
||
)).json()["seconds_to_next_measurement"]
|
||
|
||
# The line stays down for ten minutes.
|
||
await _advance_clock(db_session, run_id, timedelta(minutes=10))
|
||
|
||
resumed = (await client.post(
|
||
f"/api/production-runs/{run_id}/resume",
|
||
headers=auth_headers(measurement_tec_user), json=creds,
|
||
)).json()
|
||
assert resumed["status"] == "running"
|
||
# The ten minutes of stop were handed back, not charged to the operator.
|
||
assert abs(resumed["seconds_to_next_measurement"] - frozen) <= 2
|
||
|
||
|
||
async def test_cycle_refused_while_paused(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-PCYC")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PCYC")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
supervisor, password = await _supervisor(db_session, username="capo-pcyc")
|
||
|
||
await client.post(
|
||
f"/api/production-runs/{run_id}/pause",
|
||
headers=auth_headers(measurement_tec_user),
|
||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||
)
|
||
resp = await client.post(
|
||
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
|
||
)
|
||
assert resp.status_code == 409
|
||
|
||
|
||
async def test_close_stops_the_timer_and_frees_the_station(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-CLOSE")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-CLOSE")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
supervisor, password = await _supervisor(db_session, username="capo-close")
|
||
|
||
closed = (await client.post(
|
||
f"/api/production-runs/{run_id}/close",
|
||
headers=auth_headers(measurement_tec_user),
|
||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||
)).json()
|
||
assert closed["status"] == "closed"
|
||
assert closed["closed_by"] == supervisor.id
|
||
assert closed["seconds_to_next_measurement"] is None
|
||
|
||
# Nothing is open at the station any more...
|
||
current = await client.get(
|
||
"/api/production-runs/current",
|
||
params={"station_code": station.code},
|
||
headers=auth_headers(measurement_tec_user),
|
||
)
|
||
assert current.json() is None
|
||
|
||
# ...and a new production can start there.
|
||
again = await _open(client, measurement_tec_user, station, recipe)
|
||
assert again.status_code == 201
|
||
|
||
|
||
async def test_closed_run_refuses_further_actions(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-DEAD")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-DEAD")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
supervisor, password = await _supervisor(db_session, username="capo-dead")
|
||
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
|
||
|
||
await client.post(
|
||
f"/api/production-runs/{run_id}/close",
|
||
headers=auth_headers(measurement_tec_user), json=creds,
|
||
)
|
||
cycle = await client.post(
|
||
f"/api/production-runs/{run_id}/cycle", headers=auth_headers(measurement_tec_user),
|
||
)
|
||
assert cycle.status_code == 409
|
||
pause = await client.post(
|
||
f"/api/production-runs/{run_id}/pause",
|
||
headers=auth_headers(measurement_tec_user), json=creds,
|
||
)
|
||
assert pause.status_code == 409
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# The trace
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def test_run_records_what_happened(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""The history a production never had: start, cycles, stop, resume, close."""
|
||
station = await _station(db_session, admin_user.id, code="ST-TRACE")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-TRACE")
|
||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||
supervisor, password = await _supervisor(db_session, username="capo-trace")
|
||
creds = {"supervisor_username": supervisor.username, "supervisor_password": password}
|
||
headers = auth_headers(measurement_tec_user)
|
||
|
||
await client.post(f"/api/production-runs/{run_id}/cycle", headers=headers)
|
||
await client.post(f"/api/production-runs/{run_id}/pause", headers=headers, json=creds)
|
||
await client.post(f"/api/production-runs/{run_id}/resume", headers=headers, json=creds)
|
||
await client.post(f"/api/production-runs/{run_id}/close", headers=headers, json=creds)
|
||
|
||
events = (await client.get(
|
||
f"/api/production-runs/{run_id}", headers=headers,
|
||
)).json()["events"]
|
||
assert [e["event_type"] for e in events] == [
|
||
"start", "cycle_completed", "line_stop", "resume", "close",
|
||
]
|
||
# Who authorised what is on the record.
|
||
by_type = {e["event_type"]: e for e in events}
|
||
assert by_type["line_stop"]["supervisor_id"] == supervisor.id
|
||
assert by_type["close"]["supervisor_id"] == supervisor.id
|
||
assert by_type["cycle_completed"]["supervisor_id"] is None
|
||
|
||
|
||
async def test_traceability_travels_with_the_run(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
station = await _station(db_session, admin_user.id, code="ST-LOT")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-LOT")
|
||
body = (await _open(
|
||
client, measurement_tec_user, station, recipe,
|
||
lot_number="LOT-42", serial_number="SN-7",
|
||
)).json()
|
||
assert body["lot_number"] == "LOT-42"
|
||
assert body["serial_number"] == "SN-7"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fine produzione: the statistics file (point 6)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def _measure(client, user, run_id, subtask_id, version_id, value):
|
||
return await client.post(
|
||
"/api/measurements/",
|
||
headers=auth_headers(user),
|
||
json={
|
||
"subtask_id": subtask_id,
|
||
"version_id": version_id,
|
||
"value": value,
|
||
"lot_number": "LOT-STAT",
|
||
"production_run_id": run_id,
|
||
},
|
||
)
|
||
|
||
|
||
async def _first_subtask(db_session, recipe_id: int):
|
||
from src.backend.models.orm.recipe import RecipeVersion
|
||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||
|
||
row = await db_session.execute(
|
||
select(RecipeSubtask)
|
||
.join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
|
||
.join(RecipeVersion, RecipeVersion.id == RecipeTask.version_id)
|
||
.where(RecipeVersion.recipe_id == recipe_id)
|
||
)
|
||
return row.scalars().first()
|
||
|
||
|
||
async def test_measurement_is_tied_to_the_run(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""Without this link 'the measurements of this production' is not a query."""
|
||
station = await _station(db_session, admin_user.id, code="ST-LINK")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-LINK")
|
||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||
subtask = await _first_subtask(db_session, recipe.id)
|
||
|
||
resp = await _measure(
|
||
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["production_run_id"] == opened["id"]
|
||
|
||
|
||
async def test_close_emits_the_statistics_file(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
|
||
monkeypatch,
|
||
):
|
||
from src.backend.config import settings
|
||
from src.backend.services import production_export_service
|
||
|
||
monkeypatch.setattr(
|
||
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||
)
|
||
|
||
station = await _station(db_session, admin_user.id, code="ST-STAT")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-STAT")
|
||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||
subtask = await _first_subtask(db_session, recipe.id)
|
||
await _measure(
|
||
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
|
||
)
|
||
supervisor, password = await _supervisor(db_session, username="capo-stat")
|
||
|
||
closed = (await client.post(
|
||
f"/api/production-runs/{opened['id']}/close",
|
||
headers=auth_headers(measurement_tec_user),
|
||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||
)).json()
|
||
|
||
assert closed["statistics_path"], "la chiusura deve produrre il file di statistica"
|
||
written = tmp_path / closed["statistics_path"]
|
||
assert written.exists()
|
||
|
||
text = written.read_text(encoding="utf-8-sig")
|
||
lines = [line for line in text.splitlines() if line.strip()]
|
||
assert len(lines) == 2, "intestazione piu' una misura"
|
||
assert "production_run_id" in lines[0]
|
||
# The tolerance limits travel with the row: a pass/fail must stay re-derivable.
|
||
for column in ("ltl", "lwl", "uwl", "utl", "nominal"):
|
||
assert column in lines[0]
|
||
assert "LOT-STAT" in lines[1]
|
||
assert str(opened["id"]) in lines[1]
|
||
|
||
|
||
async def test_close_marks_measurements_as_exported(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
|
||
monkeypatch,
|
||
):
|
||
from src.backend.config import settings
|
||
from src.backend.models.orm.measurement import Measurement
|
||
|
||
monkeypatch.setattr(
|
||
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||
)
|
||
|
||
station = await _station(db_session, admin_user.id, code="ST-SYNC")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-SYNC")
|
||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||
subtask = await _first_subtask(db_session, recipe.id)
|
||
created = (await _measure(
|
||
client, measurement_tec_user, opened["id"], subtask.id, opened["version_id"], 10.0,
|
||
)).json()
|
||
assert created["synced_to_csv"] is False
|
||
|
||
supervisor, password = await _supervisor(db_session, username="capo-sync")
|
||
await client.post(
|
||
f"/api/production-runs/{opened['id']}/close",
|
||
headers=auth_headers(measurement_tec_user),
|
||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||
)
|
||
|
||
row = (await db_session.execute(
|
||
select(Measurement).where(Measurement.id == created["id"])
|
||
)).scalar_one()
|
||
await db_session.refresh(row)
|
||
assert row.synced_to_csv is True
|
||
|
||
|
||
async def test_close_without_measurements_writes_no_file(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session, tmp_path,
|
||
monkeypatch,
|
||
):
|
||
"""An empty file would be noise in the folder, not evidence."""
|
||
from src.backend.config import settings
|
||
|
||
monkeypatch.setattr(
|
||
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||
)
|
||
|
||
station = await _station(db_session, admin_user.id, code="ST-EMPTY")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-EMPTY")
|
||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||
supervisor, password = await _supervisor(db_session, username="capo-empty")
|
||
|
||
closed = (await client.post(
|
||
f"/api/production-runs/{opened['id']}/close",
|
||
headers=auth_headers(measurement_tec_user),
|
||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||
)).json()
|
||
assert closed["statistics_path"] is None
|
||
assert not (tmp_path / "statistics").exists()
|
||
|
||
|
||
async def test_statistics_csv_writes_nan_value_and_deviation(
|
||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||
):
|
||
"""#124: una quota NaN (value NULL) esce NaN sia nel valore sia nella deviazione."""
|
||
from src.backend.services import measurement_service, production_export_service
|
||
|
||
station = await _station(db_session, admin_user.id, code="ST-NAN")
|
||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-NAN")
|
||
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||
subtask = await _first_subtask(db_session, recipe.id)
|
||
await measurement_service.save_measurement(
|
||
db_session, subtask_id=subtask.id, version_id=opened["version_id"],
|
||
measured_by=measurement_tec_user.id, value=None, input_method="camera",
|
||
production_run_id=opened["id"], enforce_tolerance_gate=False,
|
||
)
|
||
await db_session.commit()
|
||
run = (await db_session.execute(
|
||
select(ProductionRun).where(ProductionRun.id == opened["id"])
|
||
)).scalar_one()
|
||
|
||
text = await production_export_service.build_statistics_csv(db_session, run)
|
||
lines = [line for line in text.lstrip("").splitlines() if line.strip()]
|
||
header = lines[0].split(",")
|
||
row = lines[1].split(",")
|
||
assert row[header.index("value")] == "NaN"
|
||
assert row[header.index("deviation")] == "NaN"
|