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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user