Files
TieMeasureFlow/src/backend/tests/test_recipe_rules.py
T
Adriano Dal Pastro 5aa3d595ad feat(recipes): la ricetta decide cosa pretendere e come si misura
Tre cose che prima non erano di nessuno diventano regole della ricetta, decise da
chi la scrive e fatte valere dal server.

Punto 8 — tracciabilita' obbligatoria. Lotto e seriale si dichiarano obbligatori
sulla ricetta. L'operatore li inserisce alla selezione, dove il pulsante non si
attiva finche' mancano, e la stessa regola vale sulla lista task, raggiungibile
anche per link diretto, e sul barcode: uno sbarramento che dura solo finche'
qualcuno prende in mano il lettore non e' uno sbarramento. La produzione non si
apre e la misura non si salva senza cio' che la ricetta pretende, perche' un
valore senza il suo lotto non e' riconducibile a niente, e accorgersene dopo
significa accorgersene tardi.

Punto 9 — inserimento manuale. Una ricetta puo' vietare i valori digitati, ed e'
il valore predefinito: il calibro e' lo strumento, digitare e' cio' che va
concesso. Dove e' vietato il tastierino non viene disegnato (non nascosto con i
CSS: il markup nascosto e' markup che si puo' rimostrare) e restano correzione e
conferma, perche' una lettura sbagliata va cancellata. Il controllo vero e' sul
server: una regola che vive solo nel frontend e' un consiglio.

Migliorato al passaggio il riconoscimento del calibro. Contava solo la raffica di
cifre, cosi' una lettura corta come "9.5" — tre battute — finiva registrata come
digitata a mano; ora conta anche l'Invio che il wedge manda dentro la stessa
raffica. Senza questa correzione il divieto avrebbe respinto misure legittime.

Punto 11 — formattazione delle descrizioni. A capo e grassetto sopravvivono: chi
scrive le ricette incolla dal PDF della scheda tecnica e il testo arrivava
appiattito, da risistemare a mano ogni volta. Nessun HTML viene accettato o
salvato — il testo viene escapato e gli unici tag nel risultato sono quelli
prodotti dal renderer. La sanificazione e' questa: non c'e' niente da sanificare
perche' non si accetta niente. Le stesse due regole in Jinja e in JS, cosi' una
descrizione si legge uguale ovunque. E la descrizione ora si vede anche in
esecuzione: era scritta per l'operatore e la vedeva solo chi la scriveva.

Migrazione 009: tre colonne sulla ricetta. Le due di tracciabilita' partono
false, che e' il comportamento di oggi; l'inserimento manuale parte *vero* sulle
ricette gia' esistenti — il default della colonna e' falso, quindi le ricette
nuove sono solo-calibro, ma spegnerlo d'ufficio su quelle in uso fermerebbe una
linea alla misura successiva. Chi possiede la ricetta lo decide dall'editor.

Test: +26 (291). Coprono il rifiuto sul server per lotto, seriale e valore
digitato, il calibro sempre ammesso, le regole che sopravvivono alla nuova
versione, il tastierino assente in pagina, l'Avvia sbarrato, e il renderer delle
descrizioni compreso il caso in cui si prova a farci passare un tag.

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

281 lines
10 KiB
Python

"""What a recipe is allowed to demand of the operator (points 8 and 9).
Two rules that used to be nobody's: whether the lot and the serial are compulsory,
and whether a value may be typed rather than read from the caliper. Both are
declared on the recipe and enforced on the server, because a rule enforced only by
the screen is a suggestion - the keypad can be hidden and the same request still
sent by hand.
"""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.recipe import Recipe, 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 _rules(db_session, recipe: Recipe, **flags) -> Recipe:
for key, value in flags.items():
setattr(recipe, key, value)
await db_session.commit()
await db_session.refresh(recipe)
return recipe
async def _first_subtask(db_session, recipe_id: int) -> RecipeSubtask:
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 _current_version_id(db_session, recipe_id: int) -> int:
row = await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)
return row.scalar_one().id
async def _measure(client, user, subtask_id, version_id, **extra):
return await client.post(
"/api/measurements/",
headers=auth_headers(user),
json={
"subtask_id": subtask_id, "version_id": version_id, "value": 10.0, **extra,
},
)
# ---------------------------------------------------------------------------
# The rules are part of the recipe
# ---------------------------------------------------------------------------
async def test_new_recipe_forbids_typing_by_default(client: AsyncClient, maker_user):
"""The caliper is the instrument: typing is what has to be asked for."""
resp = await client.post(
"/api/recipes",
headers=auth_headers(maker_user),
json={"code": "REG-DEF", "name": "Regole di default"},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["allow_manual_input"] is False
assert body["requires_lot"] is False
assert body["requires_serial"] is False
async def test_recipe_carries_the_rules_it_was_created_with(
client: AsyncClient, maker_user,
):
resp = await client.post(
"/api/recipes",
headers=auth_headers(maker_user),
json={
"code": "REG-SET", "name": "Con regole",
"requires_lot": True, "requires_serial": True, "allow_manual_input": True,
},
)
body = resp.json()
assert (body["requires_lot"], body["requires_serial"], body["allow_manual_input"]) \
== (True, True, True)
async def test_rules_can_be_switched_off_again(
client: AsyncClient, maker_user, db_session,
):
"""False must reach the database: a flag that only ever turns on is a trap."""
recipe = await create_test_recipe(db_session, maker_user.id, code="REG-OFF")
await _rules(db_session, recipe, requires_lot=True, allow_manual_input=True)
resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"requires_lot": False, "allow_manual_input": False},
)
assert resp.status_code == 200, resp.text
assert resp.json()["requires_lot"] is False
assert resp.json()["allow_manual_input"] is False
async def test_rules_survive_a_new_version(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""Editing a recipe with measurements copies it; the rules must come along."""
recipe = await create_test_recipe(db_session, maker_user.id, code="REG-VER")
await _rules(db_session, recipe, requires_lot=True)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
# A measurement on the current version forces copy-on-write on the next edit.
await _measure(
client, measurement_tec_user, subtask.id, version_id, lot_number="L-1",
)
resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={"name": "Rinominata"},
)
assert resp.status_code == 200
assert resp.json()["requires_lot"] is True
# ---------------------------------------------------------------------------
# Point 8 - traceability
# ---------------------------------------------------------------------------
async def test_measurement_without_the_required_lot_is_refused(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-LOT")
await _rules(db_session, recipe, requires_lot=True)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
refused = await _measure(client, measurement_tec_user, subtask.id, version_id)
assert refused.status_code == 422
# Blank is not a lot number either.
blank = await _measure(
client, measurement_tec_user, subtask.id, version_id, lot_number=" ",
)
assert blank.status_code == 422
accepted = await _measure(
client, measurement_tec_user, subtask.id, version_id, lot_number="LOT-9",
)
assert accepted.status_code == 200
async def test_measurement_without_the_required_serial_is_refused(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-SER")
await _rules(db_session, recipe, requires_serial=True)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
assert (await _measure(
client, measurement_tec_user, subtask.id, version_id,
)).status_code == 422
assert (await _measure(
client, measurement_tec_user, subtask.id, version_id, serial_number="SN-1",
)).status_code == 200
async def test_optional_traceability_still_accepts_nothing(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""Recipes that do not ask for a lot must go on working as they did."""
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-OPT")
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
assert (await _measure(
client, measurement_tec_user, subtask.id, version_id,
)).status_code == 200
async def test_production_cannot_start_without_the_required_lot(
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
):
"""The lot has to be there before the first value, not after it."""
recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-RUN")
await _rules(db_session, recipe, requires_lot=True)
station = Station(
code="ST-TRC", name="Stazione", active=True, created_by=admin_user.id,
)
db_session.add(station)
await db_session.commit()
refused = await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={"station_code": station.code, "recipe_id": recipe.id},
)
assert refused.status_code == 422
started = await client.post(
"/api/production-runs",
headers=auth_headers(measurement_tec_user),
json={
"station_code": station.code, "recipe_id": recipe.id, "lot_number": "L-7",
},
)
assert started.status_code == 201
# ---------------------------------------------------------------------------
# Point 9 - typed values
# ---------------------------------------------------------------------------
async def test_typed_value_is_refused_when_the_recipe_forbids_it(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-NO")
await _rules(db_session, recipe, allow_manual_input=False)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
refused = await _measure(
client, measurement_tec_user, subtask.id, version_id, input_method="manual",
)
assert refused.status_code == 422
assert "caliper" in refused.json()["detail"].lower()
async def test_the_caliper_is_always_welcome(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-USB")
await _rules(db_session, recipe, allow_manual_input=False)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
resp = await _measure(
client, measurement_tec_user, subtask.id, version_id,
input_method="usb_caliper",
)
assert resp.status_code == 200
assert resp.json()["input_method"] == "usb_caliper"
async def test_typing_is_allowed_where_the_recipe_says_so(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-YES")
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
resp = await _measure(
client, measurement_tec_user, subtask.id, version_id, input_method="manual",
)
assert resp.status_code == 200
async def test_the_default_input_method_is_covered_by_the_rule(
client: AsyncClient, maker_user, measurement_tec_user, db_session,
):
"""A request that says nothing about how the value was entered counts as typed.
Otherwise omitting the field would be the way around the rule.
"""
recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-DEF")
await _rules(db_session, recipe, allow_manual_input=False)
subtask = await _first_subtask(db_session, recipe.id)
version_id = await _current_version_id(db_session, recipe.id)
assert (await _measure(
client, measurement_tec_user, subtask.id, version_id,
)).status_code == 422