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>
This commit is contained in:
Adriano Dal Pastro
2026-07-28 20:27:19 +00:00
parent bde8fafd77
commit 5aa3d595ad
26 changed files with 1945 additions and 745 deletions
@@ -0,0 +1,213 @@
"""The screens that carry the rules of a recipe (points 8, 9 and 11).
The server refuses what the recipe forbids; these tests are about the operator
being told before the refusal rather than after it - a keypad that is not there,
a start button that will not start, a description that reads the way it was
written.
"""
# ---------------------------------------------------------------------------
# Point 11 - line breaks and bold, and nothing else
# ---------------------------------------------------------------------------
def _render(flask_app, template_string, **context):
with flask_app.test_request_context():
return flask_app.jinja_env.from_string(template_string).render(**context)
def test_rich_text_keeps_line_breaks(flask_app):
out = _render(flask_app, "{{ v|rich_text }}", v="prima\nseconda")
assert out == "prima<br>seconda"
def test_rich_text_makes_bold(flask_app):
out = _render(flask_app, "{{ v|rich_text }}", v="quota **critica** qui")
assert out == "quota <strong>critica</strong> qui"
def test_rich_text_escapes_everything_else(flask_app):
"""No HTML is accepted, so there is nothing to sanitise away later."""
out = _render(flask_app, "{{ v|rich_text }}", v="<script>alert(1)</script>")
assert "<script>" not in out
assert "&lt;script&gt;" in out
def test_rich_text_cannot_be_tricked_into_tags(flask_app):
"""Bold markers around a tag must not reassemble it."""
out = _render(flask_app, "{{ v|rich_text }}", v="**<b onclick='x'>ciao</b>**")
assert "onclick" not in out or "&#39;" in out
assert "<b " not in out
assert out.startswith("<strong>&lt;b")
def test_rich_text_of_nothing_is_nothing(flask_app):
assert _render(flask_app, "{{ v|rich_text }}", v=None) == ""
assert _render(flask_app, "{{ v|rich_text }}", v="") == ""
# ---------------------------------------------------------------------------
# Point 9 - the keypad is not offered when the recipe forbids typing
# ---------------------------------------------------------------------------
TASK = {
"id": 11,
"recipe_id": 3,
"version_id": 5,
"task_type": "measure",
"title": "Quota",
"directive": None,
"description": "prima riga\n**grassetto**",
"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",
}],
}
def _execute_page(client, mock_api_client, allow_manual):
mock_api_client.get.side_effect = [
TASK,
[{"id": 11, "order_index": 0}],
{
"id": 3, "measurement_interval_minutes": 30,
"allow_manual_input": allow_manual,
},
]
resp = client.get("/measure/execute/11")
assert resp.status_code == 200
return resp.get_data(as_text=True)
def test_keypad_digits_are_absent_when_typing_is_forbidden(
logged_in_client, mock_api_client,
):
html = _execute_page(logged_in_client, mock_api_client, allow_manual=False)
assert "allowManual: false" in html
assert "addDigit('7')" not in html, "i tasti numerici non devono essere in pagina"
# The corrections and the confirm stay: a wrong caliper reading has to be
# clearable, and the value still has to be confirmable.
assert "clearAll()" in html
assert "confirm()" in html
def test_keypad_is_there_when_the_recipe_allows_typing(
logged_in_client, mock_api_client,
):
html = _execute_page(logged_in_client, mock_api_client, allow_manual=True)
assert "allowManual: true" in html
assert "addDigit('7')" in html
def test_keypad_stays_when_the_recipe_cannot_be_read(
logged_in_client, mock_api_client,
):
"""A recipe that failed to load must not silently take the keypad away."""
mock_api_client.get.side_effect = [
TASK,
[{"id": 11, "order_index": 0}],
{"error": True, "detail": "boom"},
]
resp = logged_in_client.get("/measure/execute/11")
assert resp.status_code == 200
assert "allowManual: true" in resp.get_data(as_text=True)
# ---------------------------------------------------------------------------
# Point 11 on the measurement screen
# ---------------------------------------------------------------------------
def test_the_operator_sees_the_description_formatted(
logged_in_client, mock_api_client,
):
"""It was written for the operator, and until now only the maker saw it."""
html = _execute_page(logged_in_client, mock_api_client, allow_manual=True)
assert "prima riga<br><strong>grassetto</strong>" in html
# ---------------------------------------------------------------------------
# Point 8 - the start control knows what the recipe demands
# ---------------------------------------------------------------------------
def _task_list(client, mock_api_client, recipe, session_lot=None):
# The fixture session is English; these assertions read the Italian source
# strings, so the locale is pinned rather than assumed.
with client.session_transaction() as sess:
sess["language"] = "it"
if session_lot is not None:
sess["lot_number"] = session_lot
mock_api_client.get.side_effect = [
recipe,
[{"id": 11, "title": "Task", "order_index": 0, "task_type": "measure"}],
]
resp = client.get("/measure/tasks/3")
assert resp.status_code == 200
return resp.get_data(as_text=True)
def test_avvia_does_not_start_without_the_required_lot(
logged_in_client, mock_api_client,
):
"""This page is reachable directly, so the rule has to hold here too."""
html = _task_list(
logged_in_client, mock_api_client,
{"id": 3, "code": "REC-1", "name": "Con lotto", "requires_lot": True},
)
assert "Compila prima" in html
# The way in is closed, not merely greyed: it leads back to where the lot is
# asked for. (The per-task links below belong to the Maker's own view.)
assert "/measure/select?recipe=REC-1" in html
def test_avvia_starts_once_the_lot_is_there(logged_in_client, mock_api_client):
html = _task_list(
logged_in_client, mock_api_client,
{"id": 3, "code": "REC-1", "name": "Con lotto", "requires_lot": True},
session_lot="LOT-1",
)
assert "Compila prima" not in html
assert "/measure/select?recipe=REC-1" not in html
def test_avvia_is_untouched_when_nothing_is_required(
logged_in_client, mock_api_client,
):
html = _task_list(
logged_in_client, mock_api_client,
{"id": 3, "code": "REC-1", "name": "Libera"},
)
assert "Compila prima" not in html
assert "/measure/select?recipe=REC-1" not in html
def test_selection_screen_carries_the_traceability_rules(
logged_in_client, mock_api_client, monkeypatch,
):
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import importlib
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [
{
"id": 1, "code": "REC-001", "name": "Con lotto",
"requires_lot": True, "requires_serial": False,
},
]
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
assert "missingTraceability" in html
# The rule travels with the recipe data the cards are built from.
assert "requires_lot" in html
@@ -266,6 +266,39 @@ def test_task_execute_inline_js_is_valid(logged_in_client, mock_measure_api):
_check_alpine_attributes(html, "/measure/execute")
def test_select_recipe_inline_js_is_valid(logged_in_client, mock_measure_api, monkeypatch):
"""The selection screen decides whether Avvia starts at all.
Its expressions are inline in x-data, so a broken one there takes the search,
the barcode scanner and the traceability check down with it.
"""
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import importlib
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
_force_italian(logged_in_client)
mock_measure_api.get_station_recipes.return_value = [
{
"id": 1, "code": "REC-001", "name": "Ricetta con l'apostrofo",
"description": "Con l'apostrofo anche qui",
"requires_lot": True, "requires_serial": True,
},
]
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
for i, body in enumerate(_INLINE_SCRIPT_RX.findall(html)):
_node_check(body, f"/measure/select script[{i}]")
_check_alpine_attributes(html, "/measure/select")
def test_task_list_inline_js_is_valid(logged_in_client, mock_measure_api):
"""The task list carries the shared measurement clock.