Files
TieMeasureFlow/src/frontend/flask_app/tests/test_measure.py
T
Adriano Dal Pastro 98b1c5ae85 feat(measure): l'operatore entra nella sequenza, non in un elenco
Punto 10. Sceglere una ricetta apriva la lista completa dei task e chiedeva
all'operatore di decidere da dove cominciare prima ancora di aver fatto
qualcosa. Ora AVVIA IN SEQUENZA porta dentro il primo task; la lista resta,
un livello sotto, per quando serve vederla tutta o tornare indietro.

- nuova rotta /measure/start/<ricetta>: memorizza lotto e seriale (prima lo
  faceva la lista, che ora si salta), verifica la tracciabilità obbligatoria
  e apre il primo task
- GET /api/measurements/task-progress: quante quote ha già preso ogni task di
  misura, contate per quota e non per tentativo, delimitate dalla produzione
  aperta o, fuori produzione, dall'operatore
- la lista distingue «Incompiuto 1/3» da «Completato 3/3»: un task lasciato a
  metà non somigliava più a uno mai aperto
- «Visualizza singolo TASK» non è più riservato al Maker — dire «incompiuto»
  senza dare la strada per tornarci sarebbe una lamentela, non una funzione
- nel task «Riepilogo» diventa «Completato» e si apre solo quando il ciclo è
  chiuso: altrimenti sarebbe la scorciatoia che rende facoltative le quote
- «Fine ciclo misura» si vede da subito, spento, e dice quante quote mancano;
  prima compariva a task già finito, quando non serviva più saperlo

Il pulsante verde del footer diventa «Task successivo»: due bottoni con la
stessa parola addosso sono uno di troppo.

Test: 334 (erano 309). Il side effect dei mock del client Flask ora risponde
per endpoint invece che per turno — una lista posizionale si rompe appena una
pagina fa una domanda in più al server, che è come crescono tutte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:31:42 +00:00

318 lines
12 KiB
Python

"""Tests for measure blueprint (client/blueprints/measure.py).
Covers recipe selection, task list, login requirement, and measurement submission.
"""
import pytest
from tests.conftest import api_get_router
class TestSelectRecipe:
"""GET /measure/select tests."""
def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch):
"""Recipe selection page renders for MeasurementTec role."""
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
]
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
def test_select_recipe_requires_login(self, client):
"""Unauthenticated user is redirected to login."""
resp = client.get("/measure/select", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestTaskList:
"""GET /measure/tasks/<recipe_id> tests."""
def test_task_list_renders(self, logged_in_client, mock_api_client):
"""Task list page renders with recipe and task data."""
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": {
"items": [
{"id": 1, "title": "Task 1", "order_index": 0},
{"id": 2, "title": "Task 2", "order_index": 1},
],
},
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
"/api/production-runs/current": None,
})
resp = logged_in_client.get("/measure/tasks/1")
assert resp.status_code == 200
def test_task_list_marks_the_task_left_half_done(
self, logged_in_client, mock_api_client,
):
"""A task with some of its quotes taken says so, and says how many."""
with logged_in_client.session_transaction() as sess:
sess["language"] = "it"
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [
{"id": 7, "title": "Misura", "order_index": 0, "task_type": "measure"},
{"id": 8, "title": "Nota", "order_index": 1, "task_type": "note"},
],
"/api/recipes/1": {
"id": 1, "code": "REC-001", "name": "Test Recipe",
"current_version": {"id": 42, "version_number": 1},
},
"/api/production-runs/current": None,
"/api/measurements/task-progress": {
"tasks": [
{"task_id": 7, "quotes": 3, "measured": 1, "state": "partial"},
],
},
})
resp = logged_in_client.get("/measure/tasks/1")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
assert "Incompiuto" in html
assert "1/3" in html
def test_task_list_survives_a_progress_lookup_that_fails(
self, logged_in_client, mock_api_client,
):
"""Losing the badges must not lose the page."""
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [
{"id": 7, "title": "Misura", "order_index": 0, "task_type": "measure"},
],
"/api/recipes/1": {
"id": 1, "code": "REC-001", "name": "Test Recipe",
"current_version": {"id": 42},
},
"/api/production-runs/current": None,
"/api/measurements/task-progress": {
"error": True, "status_code": 500, "detail": "boom",
},
})
resp = logged_in_client.get("/measure/tasks/1")
assert resp.status_code == 200
assert "Misura" in resp.get_data(as_text=True)
class TestStartSequence:
"""GET /measure/start/<recipe_id> — the operator's way in."""
def test_start_goes_to_the_first_task(self, logged_in_client, mock_api_client):
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [
{"id": 31, "title": "Nota", "order_index": 1},
{"id": 30, "title": "Prima", "order_index": 0},
],
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
})
resp = logged_in_client.get("/measure/start/1?lot_number=L1")
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/measure/execute/30")
def test_start_remembers_the_traceability(self, logged_in_client, mock_api_client):
"""The list used to be where lot and serial were stored; the sequence
skips it, so the storing has to happen on the way in."""
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
})
logged_in_client.get("/measure/start/1?lot_number=L9&serial_number=S9")
with logged_in_client.session_transaction() as sess:
assert sess["lot_number"] == "L9"
assert sess["serial_number"] == "S9"
def test_start_refuses_without_the_compulsory_lot(
self, logged_in_client, mock_api_client,
):
"""Point 8 holds on this door too: it is a way into the measurement."""
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [{"id": 30, "title": "Prima", "order_index": 0}],
"/api/recipes/1": {
"id": 1, "code": "REC-001", "name": "Test Recipe",
"requires_lot": True,
},
})
resp = logged_in_client.get("/measure/start/1")
assert resp.status_code == 302
assert "/measure/select" in resp.headers["Location"]
def test_start_without_tasks_falls_back_to_the_list(
self, logged_in_client, mock_api_client,
):
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/1/tasks": [],
"/api/recipes/1": {"id": 1, "code": "REC-001", "name": "Test Recipe"},
})
resp = logged_in_client.get("/measure/start/1")
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/measure/tasks/1")
class TestTaskComplete:
"""GET /measure/complete/<recipe_id> tests."""
def test_task_complete_renders(self, logged_in_client, mock_api_client):
"""The summary carries the measurement clock, so it has to render with it."""
mock_api_client.get.side_effect = [
# The summary serialises the version number, so the recipe carries one.
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
{"items": []}, # measurements
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
assert b"productionClock(" in resp.data
class TestOutOfTolerance:
"""Proxies for point 5: the capoturno's answer has to reach the measurement."""
def test_authorise_forwards_the_credentials(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"id": 9, "supervisor_id": 4}
resp = logged_in_client.post(
"/measure/api/measurements/9/authorise",
json={"supervisor_username": "capo", "supervisor_password": "segreta"},
)
assert resp.status_code == 200
endpoint, kwargs = mock_api_client.post.call_args
assert endpoint[0] == "/api/measurements/9/authorise"
assert kwargs["data"]["supervisor_username"] == "capo"
def test_authorise_needs_credentials(self, logged_in_client, mock_api_client):
resp = logged_in_client.post(
"/measure/api/measurements/9/authorise", json={},
)
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_authorise_propagates_refusal(self, logged_in_client, mock_api_client):
"""A operator who is not a supervisor must not authorise their own value."""
mock_api_client.post.return_value = {
"error": True, "status_code": 403, "detail": "not a supervisor",
}
resp = logged_in_client.post(
"/measure/api/measurements/9/authorise",
json={"supervisor_username": "tec", "supervisor_password": "x"},
)
assert resp.status_code == 403
def test_pending_passes_the_scope(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"id": 9, "subtask_id": 21}
resp = logged_in_client.get(
"/measure/api/measurements/pending-authorisation"
"?version_id=5&production_run_id=7",
)
assert resp.status_code == 200
assert resp.get_json()["id"] == 9
endpoint, kwargs = mock_api_client.get.call_args
assert endpoint[0] == "/api/measurements/pending-authorisation"
assert kwargs["params"] == {"version_id": "5", "production_run_id": "7"}
def test_pending_without_a_run_omits_it(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = None
logged_in_client.get(
"/measure/api/measurements/pending-authorisation?version_id=5",
)
assert mock_api_client.get.call_args[1]["params"] == {"version_id": "5"}
def test_routes_require_login(self, client):
for method, url in (
("get", "/measure/api/measurements/pending-authorisation?version_id=5"),
("post", "/measure/api/measurements/9/authorise"),
):
resp = getattr(client, method)(url)
assert resp.status_code in (302, 401), f"{method} {url}"
class TestSaveMeasurement:
"""POST /measure/save-measurement tests."""
def test_save_measurement_proxy(self, logged_in_client, mock_api_client):
"""Save measurement forwards data to API and returns 201."""
mock_api_client.post.return_value = {
"id": 1,
"subtask_id": 10,
"version_id": 3,
"value": 9.95,
"pass_fail": "pass",
}
resp = logged_in_client.post(
"/measure/save-measurement",
json={
"subtask_id": 10,
"version_id": 3,
"value": 9.95,
},
content_type="application/json",
)
assert resp.status_code == 201
data = resp.get_json()
assert data["id"] == 1
assert data["pass_fail"] == "pass"
def test_save_measurement_forwards_input_duration(
self, logged_in_client, mock_api_client
):
"""input_duration_ms is forwarded to the backend payload."""
mock_api_client.post.return_value = {"id": 1, "pass_fail": "pass"}
resp = logged_in_client.post(
"/measure/save-measurement",
json={
"subtask_id": 10,
"version_id": 3,
"value": 9.95,
"input_duration_ms": 4250,
},
content_type="application/json",
)
assert resp.status_code == 201
payload = mock_api_client.post.call_args.kwargs["data"]
assert payload["input_duration_ms"] == 4250
def test_save_measurement_omits_input_duration_when_absent(
self, logged_in_client, mock_api_client
):
"""Without input_duration_ms the payload sends null (backend default)."""
mock_api_client.post.return_value = {"id": 1, "pass_fail": "pass"}
resp = logged_in_client.post(
"/measure/save-measurement",
json={
"subtask_id": 10,
"version_id": 3,
"value": 9.95,
},
content_type="application/json",
)
assert resp.status_code == 201
payload = mock_api_client.post.call_args.kwargs["data"]
assert payload.get("input_duration_ms") is None
def test_save_measurement_missing_fields(self, logged_in_client, mock_api_client):
"""Missing required fields return 400."""
resp = logged_in_client.post(
"/measure/save-measurement",
json={"subtask_id": 10}, # missing version_id and value
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert data["error"] is True