bde8fafd77
Il conto alla rovescia non si ferma piu' a zero. Oltre la scadenza continua nell'altro senso e dice da quanto la linea e' in ritardo, in rosso, su tutte le schermate del percorso di misura: lista task, riepilogo, scelta ricetta. Un ritardo va letto, non dedotto. Allo scadere dell'intervallo la misura si ripropone da sola: cicalino, un avviso di cinque secondi, e l'operatore torna al primo task di misura da qualunque schermata si trovi. Un ciclo copre tutti i task di misura della ricetta, quindi solo l'ultimo lo chiude e fa ripartire l'intervallo. Farlo ripartire al primo avrebbe fatto misurare una ricetta con tre task di misura tre volte piu' spesso di come e' configurata. E' il server a decidere quale task chiude il ciclo, perche' e' lui a conoscere la sequenza: la lista dei task di misura viaggia con la produzione (measurement_task_ids), che e' anche cio' che permette a una schermata qualsiasi di sapere dove riportare l'operatore. Aggiunta la rimisura: si gira il pezzo e si misura di nuovo dentro lo stesso ciclo, senza chiudere niente e senza guadagnare tempo sulla scadenza. Le due letture restano entrambe in statistica, che e' il motivo per cui si prendono. Chi sta gia' misurando quando l'intervallo scade e' in ritardo, non perso: la banda diventa rossa e lo si lascia lavorare. Portarlo altrove a meta' ciclo cancellerebbe quote che ha davanti agli occhi senza guadagnare nulla. Sistemato anche il pulsante "Conferma ciclo" dell'overlay: alzava una bandierina locale e basta, quindi il ciclo non veniva mai registrato sul server da quella strada. Ora passa da confirmCycle come il pulsante della barra. Migrazione 008: il registro eventi della produzione impara task_measured e remeasure, e la colonna task_id — un task_measured che non dice quale task non registra niente di utile. I valori nuovi entrano tutti insieme perche' allargare una enum MySQL riscrive la tabella, stesso ragionamento dei tipi di task in 007. Verificata su SQLite (batch mode) e in MySQL con --sql. La logica dell'orologio — come si legge un ritardo, quando suona, dove sta la misura — vive in un solo posto (production-clock.js) e la schermata di misura la usa invece di riscriverla. Test: +10 (265). Coprono il ciclo che non riparte a meta', la rimisura che non sposta la scadenza, il rifiuto a linea ferma, la sequenza dei task di misura esposta dalla produzione, i proxy Flask e la validita' JS della lista task. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
748 lines
30 KiB
Python
748 lines
30 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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()
|