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>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 21:31:42 +00:00
parent 919ee7ac79
commit 98b1c5ae85
15 changed files with 1656 additions and 659 deletions
+21
View File
@@ -73,6 +73,27 @@ def logged_in_client(flask_app):
yield test_client
def api_get_router(routes: dict, default=None):
"""A ``get`` side effect that answers by endpoint instead of by turn.
A list of responses in call order breaks the moment a page asks the server one
more question - which is how every page grows. Matching on the endpoint says
what a test actually means: this is what /api/recipes/3 replies.
Keys are matched as substrings of the endpoint, longest first, so
``/api/recipes/3/tasks`` wins over ``/api/recipes/3``.
"""
ordered = sorted(routes.items(), key=lambda kv: -len(kv[0]))
def _get(endpoint, *args, **kwargs):
for prefix, response in ordered:
if prefix in endpoint:
return response() if callable(response) else response
return {} if default is None else default
return _get
@pytest.fixture
def mock_api_client():
"""Patch the api_client singleton used in blueprints.
+116 -7
View File
@@ -4,6 +4,8 @@ Covers recipe selection, task list, login requirement, and measurement submissio
"""
import pytest
from tests.conftest import api_get_router
class TestSelectRecipe:
"""GET /measure/select tests."""
@@ -36,22 +38,129 @@ class TestTaskList:
def test_task_list_renders(self, logged_in_client, mock_api_client):
"""Task list page renders with recipe and task data."""
# First call: recipe details (dict), second call: tasks list.
# The route calls tasks_resp.get("error") so the mock must return
# a dict (not a bare list) to avoid AttributeError.
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
{
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."""
@@ -0,0 +1,192 @@
"""Point 10: the operator's way through a recipe.
The sequence is the normal path and the list is a place you go on purpose. What
these tests hold on to is the part that is easy to undo by accident: the way in
lands on a task, the list says which task was left unfinished and lets the
operator back into it, «Fine ciclo misura» is visible before it is usable, and
nothing in any of it opens a door to the statistics (point 15).
"""
from tests.conftest import api_get_router
TASK = {
"id": 11,
"recipe_id": 3,
"version_id": 5,
"task_type": "measure",
"title": "Quota",
"directive": None,
"description": None,
"file_path": None,
"file_type": None,
"annotations_json": None,
"subtasks": [{
"id": 21, "marker_number": 1, "description": "Altezza", "order_index": 0,
"nominal": 10.0, "utl": 10.5, "uwl": 10.2, "lwl": 9.8, "ltl": 9.5,
"unit": "mm",
}],
}
RECIPE = {
"id": 3, "code": "REC-1", "name": "Ricetta",
"measurement_interval_minutes": 30, "allow_manual_input": True,
"current_version": {"id": 5, "version_number": 1},
}
def _italian(client):
with client.session_transaction() as sess:
sess["language"] = "it"
def _operator_only(client):
"""Strip the Maker role: this is what the shop floor account looks like."""
with client.session_transaction() as sess:
user = dict(sess["user"])
user["roles"] = ["MeasurementTec"]
user["is_admin"] = False
sess["user"] = user
def _execute_page(client, mock_api_client):
_italian(client)
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/11": TASK,
"/api/recipes/3/tasks": [{"id": 11, "order_index": 0}],
"/api/recipes/3": RECIPE,
})
resp = client.get("/measure/execute/11")
assert resp.status_code == 200
return resp.get_data(as_text=True)
def _list_page(client, mock_api_client, progress=None):
_italian(client)
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3/tasks": [
{"id": 11, "title": "Misura", "order_index": 0, "task_type": "measure"},
],
"/api/recipes/3": RECIPE,
"/api/production-runs/current": None,
"/api/measurements/task-progress": {"tasks": progress or []},
})
resp = client.get("/measure/tasks/3")
assert resp.status_code == 200
return resp.get_data(as_text=True)
# ---------------------------------------------------------------------------
# The way in: a recipe opens on a task, not on a list
# ---------------------------------------------------------------------------
def test_the_selection_screen_starts_the_sequence(
logged_in_client, mock_api_client, monkeypatch,
):
_italian(logged_in_client)
# The page refuses to render without a station, which is the right answer on a
# real terminal and only an obstacle here.
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = []
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
assert "'/measure/start/'" in html
# The full list is still reachable, deliberately as the quiet option.
assert "'/measure/tasks/'" in html
def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client):
html = _list_page(logged_in_client, mock_api_client)
assert "/measure/start/3" in html
assert "AVVIA IN SEQUENZA" in html
# ---------------------------------------------------------------------------
# The list: second level, and it says what was left unfinished
# ---------------------------------------------------------------------------
def test_the_operator_can_open_a_single_task(logged_in_client, mock_api_client):
"""Without this, «incompiuto» would be a label with no way to act on it."""
_operator_only(logged_in_client)
html = _list_page(logged_in_client, mock_api_client)
assert "Visualizza singolo TASK" in html
assert "/measure/execute/11" in html
def test_a_half_measured_task_is_marked(logged_in_client, mock_api_client):
html = _list_page(
logged_in_client, mock_api_client,
progress=[{"task_id": 11, "quotes": 3, "measured": 2, "state": "partial"}],
)
assert "Incompiuto" in html
assert "2/3" in html
def test_a_finished_task_is_marked(logged_in_client, mock_api_client):
html = _list_page(
logged_in_client, mock_api_client,
progress=[{"task_id": 11, "quotes": 3, "measured": 3, "state": "complete"}],
)
assert "Completato" in html
assert "3/3" in html
def test_an_untouched_task_says_nothing(logged_in_client, mock_api_client):
"""A list that shouts at every row is a list nobody reads."""
html = _list_page(
logged_in_client, mock_api_client,
progress=[{"task_id": 11, "quotes": 3, "measured": 0, "state": "none"}],
)
assert "Incompiuto" not in html
# ---------------------------------------------------------------------------
# Inside the task: «Completato» forward, «Fine ciclo misura» visible but shut
# ---------------------------------------------------------------------------
def test_the_header_offers_completato_not_the_summary(
logged_in_client, mock_api_client,
):
html = _execute_page(logged_in_client, mock_api_client)
assert 'x-data="taskExecute()"' in html
# The header control moves the operator on and is gated by the same rule as
# the footer, rather than jumping out of the sequence to a recap.
assert 'canLeaveTask' in html
assert '@click="goToNextTask()"' in html
def test_fine_ciclo_is_shown_before_it_can_be_used(
logged_in_client, mock_api_client,
):
"""It used to appear only once the task was already finished."""
html = _execute_page(logged_in_client, mock_api_client)
assert 'x-show="!cycleConfirmed"' in html
assert ':disabled="!isComplete"' in html
assert "Fine ciclo misura" in html
def test_the_missing_quotes_are_counted_out_loud(
logged_in_client, mock_api_client,
):
html = _execute_page(logged_in_client, mock_api_client)
assert "missingQuotesLabel" in html
assert "Mancano" in html
# ---------------------------------------------------------------------------
# Point 15: none of this may open a way to the statistics
# ---------------------------------------------------------------------------
def test_the_operator_flow_never_links_to_the_statistics(
logged_in_client, mock_api_client,
):
_operator_only(logged_in_client)
pages = [
_list_page(logged_in_client, mock_api_client),
_execute_page(logged_in_client, mock_api_client),
]
for html in pages:
assert "/statistics" not in html
@@ -24,6 +24,8 @@ from unittest.mock import MagicMock, patch
import pytest
from tests.conftest import api_get_router
# Match every inline <script>…</script> (i.e. without `src=`) so we don't try
# to syntax-check Alpine.js / Plotly bundles served from a CDN.
@@ -307,13 +309,20 @@ def test_task_list_inline_js_is_valid(logged_in_client, mock_measure_api):
with it, and the operator would simply never be called back.
"""
_force_italian(logged_in_client)
mock_measure_api.get.side_effect = [
{"id": 3, "code": "REC-3", "name": "Ricetta", "description": None},
[{
mock_measure_api.get.side_effect = api_get_router({
"/api/recipes/3/tasks": [{
"id": 11, "order_index": 0, "title": "Quota d'ingresso",
"task_type": "measure", "subtasks": [], "file_path": None,
}],
]
"/api/recipes/3": {
"id": 3, "code": "REC-3", "name": "Ricetta", "description": None,
"current_version": {"id": 5},
},
"/api/production-runs/current": None,
"/api/measurements/task-progress": {
"tasks": [{"task_id": 11, "quotes": 2, "measured": 1, "state": "partial"}],
},
})
resp = logged_in_client.get("/measure/tasks/3")
assert resp.status_code == 200