5aa3d595ad
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>
281 lines
10 KiB
Python
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
|