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
+246
View File
@@ -0,0 +1,246 @@
"""Point 10: a task left half done has to look different from one never opened.
The task list showed both the same way, so an operator coming back to a recipe
had to remember where they had got to - or measure something twice. These tests
are about the count the list now puts on each card.
"""
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _version_and_task(db_session, recipe_id: int):
version = (await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
return version, task
async def _add_quote(db_session, task_id: int, marker: int):
quote = RecipeSubtask(
task_id=task_id, marker_number=marker, description=f"Quota {marker}",
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
)
db_session.add(quote)
await db_session.commit()
await db_session.refresh(quote)
return quote
async def _measure(client, user, subtask_id, version_id, value=10.0):
return await client.post(
"/api/measurements/", headers=auth_headers(user),
json={"subtask_id": subtask_id, "version_id": version_id, "value": value},
)
async def _progress(client, user, version_id):
resp = await client.get(
"/api/measurements/task-progress",
headers=auth_headers(user),
params={"version_id": version_id},
)
assert resp.status_code == 200
return {row["task_id"]: row for row in resp.json()["tasks"]}
async def test_untouched_task_reads_as_none(
client: AsyncClient, db_session, measurement_tec_user,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task = await _version_and_task(db_session, recipe.id)
rows = await _progress(client, measurement_tec_user, version.id)
assert rows[task.id]["state"] == "none"
assert rows[task.id]["measured"] == 0
async def test_half_measured_task_reads_as_partial(
client: AsyncClient, db_session, measurement_tec_user,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task = await _version_and_task(db_session, recipe.id)
first = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
await _add_quote(db_session, task.id, 2)
await _measure(client, measurement_tec_user, first.id, version.id)
rows = await _progress(client, measurement_tec_user, version.id)
assert rows[task.id] == {
"task_id": task.id, "quotes": 2, "measured": 1, "state": "partial",
}
async def test_every_quote_taken_reads_as_complete(
client: AsyncClient, db_session, measurement_tec_user,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task = await _version_and_task(db_session, recipe.id)
first = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
second = await _add_quote(db_session, task.id, 2)
await _measure(client, measurement_tec_user, first.id, version.id)
await _measure(client, measurement_tec_user, second.id, version.id)
rows = await _progress(client, measurement_tec_user, version.id)
assert rows[task.id]["state"] == "complete"
assert rows[task.id]["measured"] == 2
async def test_measuring_the_same_quote_twice_does_not_inflate_the_count(
client: AsyncClient, db_session, measurement_tec_user,
):
"""Two readings of one quote are one quote done, not two.
Re-measuring is allowed - it is how an operator recovers from a caliper that
slipped - and counting attempts would show a task of two quotes as finished
after one of them had been measured twice.
"""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task = await _version_and_task(db_session, recipe.id)
first = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
await _add_quote(db_session, task.id, 2)
await _measure(client, measurement_tec_user, first.id, version.id, 10.0)
await _measure(client, measurement_tec_user, first.id, version.id, 10.1)
rows = await _progress(client, measurement_tec_user, version.id)
assert rows[task.id]["measured"] == 1
assert rows[task.id]["state"] == "partial"
async def test_documental_tasks_are_left_out(
client: AsyncClient, db_session, measurement_tec_user,
):
"""A note cannot be half read; carrying it as unfinished for ever is noise."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task = await _version_and_task(db_session, recipe.id)
note = RecipeTask(
version_id=version.id, order_index=5, title="Leggere prima",
task_type="note",
)
db_session.add(note)
await db_session.commit()
await db_session.refresh(note)
rows = await _progress(client, measurement_tec_user, version.id)
assert note.id not in rows
assert task.id in rows
async def test_a_measurement_task_without_quotes_is_not_reported_as_missing(
client: AsyncClient, db_session, measurement_tec_user,
):
"""Declared as a measurement, no quotes entered yet: nothing to take."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, _task = await _version_and_task(db_session, recipe.id)
empty = RecipeTask(
version_id=version.id, order_index=6, title="Da compilare",
task_type="measure",
)
db_session.add(empty)
await db_session.commit()
await db_session.refresh(empty)
rows = await _progress(client, measurement_tec_user, version.id)
assert rows[empty.id]["state"] == "empty"
assert rows[empty.id]["quotes"] == 0
async def test_another_operators_work_is_not_counted_as_yours(
client: AsyncClient, db_session, measurement_tec_user, admin_user,
):
"""Outside a production the boundary is the operator, as it is everywhere else."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task = await _version_and_task(db_session, recipe.id)
first = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
await _measure(client, admin_user, first.id, version.id)
rows = await _progress(client, measurement_tec_user, version.id)
assert rows[task.id]["state"] == "none"
async def test_inside_a_production_the_run_is_the_boundary(
client: AsyncClient, db_session, measurement_tec_user, admin_user,
):
"""A quote measured before the production opened is not part of it.
The list is read during the run, and carrying yesterday's readings into it
would show a task as finished that this production has not touched.
"""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task = await _version_and_task(db_session, recipe.id)
first = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
# Measured outside any production.
await _measure(client, measurement_tec_user, first.id, version.id)
station = Station(
code="ST-PROG", name="Stazione", active=True, created_by=admin_user.id,
)
db_session.add(station)
await db_session.commit()
run = (await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)).json()
resp = await client.get(
"/api/measurements/task-progress",
headers=auth_headers(measurement_tec_user),
params={"version_id": version.id, "production_run_id": run["id"]},
)
assert resp.status_code == 200
rows = {row["task_id"]: row for row in resp.json()["tasks"]}
assert rows[task.id]["state"] == "none"
await client.post(
"/api/measurements/", headers=auth_headers(measurement_tec_user),
json={
"subtask_id": first.id, "version_id": version.id, "value": 10.0,
"production_run_id": run["id"],
},
)
resp = await client.get(
"/api/measurements/task-progress",
headers=auth_headers(measurement_tec_user),
params={"version_id": version.id, "production_run_id": run["id"]},
)
rows = {row["task_id"]: row for row in resp.json()["tasks"]}
assert rows[task.id]["state"] == "complete"
async def test_progress_needs_a_measurement_role(
client: AsyncClient, db_session, maker_user, measurement_tec_user,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, _task = await _version_and_task(db_session, recipe.id)
resp = await client.get(
"/api/measurements/task-progress",
headers=auth_headers(maker_user),
params={"version_id": version.id},
)
assert resp.status_code == 403