diff --git a/src/backend/api/routers/measurements.py b/src/backend/api/routers/measurements.py index 4e631b6..bb466d9 100644 --- a/src/backend/api/routers/measurements.py +++ b/src/backend/api/routers/measurements.py @@ -357,7 +357,8 @@ async def export_measurements_csv( # Rows for m in measurements: # Format decimals with configured separator - value_str = str(m.value).replace(".", decimal_separator) + # #118: quota non misurata dalla visione -> NaN + value_str = "NaN" if m.value is None else str(m.value).replace(".", decimal_separator) deviation_str = ( str(m.deviation).replace(".", decimal_separator) if m.deviation is not None diff --git a/src/backend/api/routers/statistics.py b/src/backend/api/routers/statistics.py index da67f0a..b785b6e 100644 --- a/src/backend/api/routers/statistics.py +++ b/src/backend/api/routers/statistics.py @@ -146,7 +146,8 @@ async def get_capability( date_from, date_to, operator_id, lot_number, serial_number, production_run_id=production_run_id, # #58 ) - values = [float(m.value) for m in measurements] + # #118: le quote NaN (value None) non entrano nei calcoli + values = [float(m.value) for m in measurements if m.value is not None] tol = await _get_subtask_tolerances(db, subtask_id) return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"]) @@ -171,6 +172,8 @@ async def get_control_chart( date_from, date_to, operator_id, lot_number, serial_number, production_run_id=production_run_id, # #58 ) + # #118: le quote NaN (value None) si saltano, valori e istanti restano allineati + measurements = [m for m in measurements if m.value is not None] values = [float(m.value) for m in measurements] timestamps = [m.measured_at for m in measurements] tol = await _get_subtask_tolerances(db, subtask_id) @@ -201,7 +204,8 @@ async def get_histogram( date_from, date_to, operator_id, lot_number, serial_number, production_run_id=production_run_id, # #58 ) - values = [float(m.value) for m in measurements] + # #118: le quote NaN (value None) non entrano nell'istogramma + values = [float(m.value) for m in measurements if m.value is not None] return compute_histogram(values, n_bins) diff --git a/src/backend/api/routers/vision.py b/src/backend/api/routers/vision.py index 626619a..9917567 100644 --- a/src/backend/api/routers/vision.py +++ b/src/backend/api/routers/vision.py @@ -162,7 +162,10 @@ async def execute( engine_version=result.engine_version, measurements=[ VisionMeasurementResult( - subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail, + subtask_id=m.subtask_id, + # #118: None = quota non prodotta, il frontend la mostra NaN + value=float(m.value) if m.value is not None else None, + pass_fail=m.pass_fail, ) for m in saved ], diff --git a/src/backend/migrations/versions/022_measurement_value_nullable.py b/src/backend/migrations/versions/022_measurement_value_nullable.py new file mode 100644 index 0000000..117d6aa --- /dev/null +++ b/src/backend/migrations/versions/022_measurement_value_nullable.py @@ -0,0 +1,39 @@ +"""measurements.value nullable (#118) + +measurements.value: NULL = quota che il grafo di visione non ha prodotto +(l'operatore la vede come NaN, esito sempre `fail`, deviazione NULL). Prima +execute rifiutava con 422 l'intera acquisizione. measurements.deviation era +gia' nullable. + +Downgrade: torna NOT NULL; FALLISCE se esistono righe con value NULL (vanno +cancellate o valorizzate prima). + +Revision ID: 022_measurement_value_nullable +Revises: 021_task_acquisition +Create Date: 2026-10-28 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '022_measurement_value_nullable' +down_revision: Union[str, None] = '021_task_acquisition' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # batch_alter_table come nelle migrazioni vicine: serve a SQLite + with op.batch_alter_table('measurements') as batch: + batch.alter_column( + 'value', existing_type=sa.DECIMAL(12, 6), nullable=True, + ) + + +def downgrade() -> None: + with op.batch_alter_table('measurements') as batch: + batch.alter_column( + 'value', existing_type=sa.DECIMAL(12, 6), nullable=False, + ) diff --git a/src/backend/models/api/measurement.py b/src/backend/models/api/measurement.py index 11ec194..c7418b4 100644 --- a/src/backend/models/api/measurement.py +++ b/src/backend/models/api/measurement.py @@ -43,7 +43,7 @@ class MeasurementResponse(BaseModel): subtask_id: int version_id: int measured_by: int - value: float + value: Optional[float] = None # #118: null = quota non prodotta dalla visione (NaN) pass_fail: str deviation: Optional[float] = None lot_number: Optional[str] = None @@ -108,7 +108,7 @@ class CycleMeasurementResponse(BaseModel): """One quote already measured in the cycle under way.""" subtask_id: int - value: float + value: Optional[float] = None # #118: null = NaN pass_fail: str measured_at: UtcDateTime diff --git a/src/backend/models/api/vision.py b/src/backend/models/api/vision.py index 155f78e..b025dcc 100644 --- a/src/backend/models/api/vision.py +++ b/src/backend/models/api/vision.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict class VisionMeasurementResult(BaseModel): """One quote's outcome, as much of it as the client needs to show.""" subtask_id: int - value: float + value: float | None # #118: null = quota non prodotta dal grafo (NaN), pass_fail "fail" pass_fail: str diff --git a/src/backend/models/orm/measurement.py b/src/backend/models/orm/measurement.py index 1020fd7..5726416 100644 --- a/src/backend/models/orm/measurement.py +++ b/src/backend/models/orm/measurement.py @@ -26,7 +26,8 @@ class Measurement(Base): ) # Measurement data - value: Mapped[float] = mapped_column(DECIMAL(12, 6), nullable=False) + # #118: NULL = quota che il grafo di visione non ha prodotto (mostrata NaN, sempre fail) + value: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True) pass_fail: Mapped[str] = mapped_column( Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False ) diff --git a/src/backend/services/measurement_service.py b/src/backend/services/measurement_service.py index 456249b..5324a05 100644 --- a/src/backend/services/measurement_service.py +++ b/src/backend/services/measurement_service.py @@ -302,7 +302,7 @@ async def current_cycle_measurements( return [ { "subtask_id": m.subtask_id, - "value": float(m.value), + "value": float(m.value) if m.value is not None else None, # #118: NaN "pass_fail": m.pass_fail, "measured_at": m.measured_at, } @@ -355,7 +355,7 @@ async def save_measurement( subtask_id: int, version_id: int, measured_by: int, - value: float, + value: float | None, lot_number: str | None = None, serial_number: str | None = None, input_method: str = "manual", @@ -372,8 +372,12 @@ async def save_measurement( batch of quotes taken at the same instant (see Ruling R10 / vision_service): the gate models "moving on" between measurements taken one after another, which does not exist inside a single camera acquisition. + + #118: ``value=None`` is a quote the vision graph did not produce (shown as + NaN): saved as a ``fail`` with no deviation. Only the vision path passes it. """ - _validate_measured_value(value) + if value is not None: # #118: una quota non misurata non ha un valore da validare + _validate_measured_value(value) # Get subtask for tolerance values result = await db.execute( @@ -421,8 +425,12 @@ async def save_measurement( # Rounded once, here, so the stored value is exactly the one the verdict # above was computed on - calculate_pass_fail rounds internally too, but # matching it here keeps the persisted row from disagreeing with itself. - value = round(value, 6) - pass_fail, deviation = calculate_pass_fail(value, subtask) + if value is None: + # #118: quota non prodotta dal grafo -> NaN: fail, nessuna deviazione + pass_fail, deviation = "fail", None + else: + value = round(value, 6) + pass_fail, deviation = calculate_pass_fail(value, subtask) measurement = Measurement( subtask_id=subtask_id, diff --git a/src/backend/services/monitor_service.py b/src/backend/services/monitor_service.py index 81533b0..7944cb1 100644 --- a/src/backend/services/monitor_service.py +++ b/src/backend/services/monitor_service.py @@ -96,7 +96,12 @@ async def list_open_runs(db: AsyncSession) -> list[MonitorRun]: per_sub[row.subtask_id].append(row) subtasks = [] for sid, group in sorted(per_sub.items()): - mean = sum(float(g.value) for g in group) / len(group) + # #118: le quote NaN (value None) non entrano nella media; senza + # nessun valore numerico la quota non ha una media da mostrare + numeric = [float(g.value) for g in group if g.value is not None] + if not numeric: + continue + mean = sum(numeric) / len(numeric) first = group[0] subtasks.append(MonitorSubtask( subtask_id=sid, description=first.description, unit=first.unit, diff --git a/src/backend/services/production_export_service.py b/src/backend/services/production_export_service.py index 4d7acdb..c547f87 100644 --- a/src/backend/services/production_export_service.py +++ b/src/backend/services/production_export_service.py @@ -159,7 +159,7 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str: subtask.marker_number if subtask else "", neutralise_csv_cell(subtask.description) if subtask else "", number(subtask.nominal) if subtask else "", - number(measurement.value), + number(measurement.value) if measurement.value is not None else "NaN", # #118 number(measurement.deviation), measurement.pass_fail, number(subtask.ltl) if subtask else "", diff --git a/src/backend/services/report_service.py b/src/backend/services/report_service.py index a032084..2c78fdd 100644 --- a/src/backend/services/report_service.py +++ b/src/backend/services/report_service.py @@ -318,8 +318,10 @@ async def generate_spc_report( # 2. Compute SPC data pass_fail_values = [m.pass_fail for m in measurements] - values = [float(m.value) for m in measurements] - timestamps = [m.measured_at for m in measurements] + # #118: le quote NaN (value None) contano nell'esito ma non nei calcoli + numeric = [m for m in measurements if m.value is not None] + values = [float(m.value) for m in numeric] + timestamps = [m.measured_at for m in numeric] summary = compute_summary(pass_fail_values) @@ -426,7 +428,7 @@ async def generate_measurement_report( rows.append({ "num": i, "subtask": f"#{st_info.get('marker_number', '?')} — {st_info.get('description', '?')}", - "value": f"{float(m.value):.4f}", + "value": f"{float(m.value):.4f}" if m.value is not None else "NaN", # #118 "unit": st_info.get("unit", "mm"), "pass_fail": m.pass_fail, "measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"), diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index e2ae9f8..a01d7de 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -693,23 +693,13 @@ async def execute_task( ) outputs = report["outputs"] - # Every quote or none. A half-measured piece read as a whole one is worse - # than a piece not measured at all. A quote counts as produced only if its - # output is present *and* a finite, non-bool number: the in-process runner - # already drops NaN/inf itself (see src/vision/runner.py), but the worker - # is reached over HTTP - a different implementation of it, or a malformed - # response, could still hand back None or a non-finite float for a mapped - # key instead of omitting it outright. - missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))] - if missing: - failed = ", ".join(f["tool_name"] for f in report["failures"]) - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=( - f"the graph did not produce {', '.join(missing)}" - + (f" (tools that failed: {failed})" if failed else "") - ), - ) + # #118: una quota che il grafo non produce non blocca piu' l'esecuzione + # (prima 422 "the graph did not produce"): viene salvata con value NULL, + # mostrata NaN, sempre `fail`; i motivi restano nei `failures` del risultato. + # Una quota conta come prodotta solo se l'uscita c'e' *ed* e' un numero + # finito non-bool: il worker risponde via HTTP, un'altra implementazione o + # una risposta malformata potrebbe dare None o un non finito per una chiave + # mappata invece di ometterla. overlay = report.get("overlay") or [] image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext) @@ -737,7 +727,11 @@ async def execute_task( subtask_id=quote.id, version_id=version_id, measured_by=measured_by, - value=float(outputs[quote.vision_output]), + # #118: None = quota non prodotta (NaN), salvata come fail + value=( + float(outputs[quote.vision_output]) + if _is_measurable(outputs.get(quote.vision_output)) else None + ), lot_number=lot_number, serial_number=serial_number, input_method="camera", @@ -762,8 +756,9 @@ async def check_task( VisionResult, niente file, niente misure. L'esito per quota usa calculate_pass_fail sulle quote con vision_output, nell'ordine di marker_number come execute. Le quote che il grafo non produce (pezzo non - ancora in posizione) restano fuori da `measurements` e il perche' sta in - `failures`: un'anteprima non deve dare 422 a ogni frame. + ancora in posizione) entrano in `measurements` con `value` None (NaN) e + `pass_fail` "fail" (#118, come execute); il perche' sta in `failures`: + un'anteprima non deve dare 422 a ogni frame. """ if task.task_type not in VISION_TASK_TYPES: raise HTTPException( @@ -809,6 +804,7 @@ async def check_task( **worker_kwargs, ) outputs = report["outputs"] + # #118: una voce per ogni quota, in ordine di marker_number; non prodotta -> NaN/fail measurements = [ { "subtask_id": q.id, @@ -816,8 +812,10 @@ async def check_task( "pass_fail": measurement_service.calculate_pass_fail( float(outputs[q.vision_output]), q, )[0], + } if _is_measurable(outputs.get(q.vision_output)) else { + "subtask_id": q.id, "value": None, "pass_fail": "fail", } - for q in quotes if _is_measurable(outputs.get(q.vision_output)) + for q in quotes ] return { "outcome": report.get("outcome"), diff --git a/src/backend/tests/test_migrations.py b/src/backend/tests/test_migrations.py index e124a82..57b8b30 100644 --- a/src/backend/tests/test_migrations.py +++ b/src/backend/tests/test_migrations.py @@ -360,3 +360,22 @@ class TestTaskAcquisitionMigration: assert "camera_url" in cols("stations") # la 020 resta command.upgrade(cfg, "head") # e di nuovo su assert "acquisition_json" in cols("recipe_tasks") + + +class TestMeasurementValueNullableMigration: + """022_measurement_value_nullable: measurements.value accetta NULL, su e giu (#118).""" + + def test_022_upgrade_and_downgrade(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + + def value_nullable(): + cols = _inspect(db_path).get_columns("measurements") + return {c["name"]: c["nullable"] for c in cols}["value"] + + command.upgrade(cfg, "head") + assert value_nullable() is True + command.downgrade(cfg, "021_task_acquisition") + assert value_nullable() is False + command.upgrade(cfg, "head") # e di nuovo su + assert value_nullable() is True diff --git a/src/backend/tests/test_nan_measurements.py b/src/backend/tests/test_nan_measurements.py new file mode 100644 index 0000000..a0d43be --- /dev/null +++ b/src/backend/tests/test_nan_measurements.py @@ -0,0 +1,114 @@ +"""#118: una quota non misurata dalla visione e' una misura con value NULL (NaN), sempre fail. + +Il valore NULL non deve far cadere statistiche, report, CSV e liste, e non entra +nei calcoli numerici (medie, indici, istogramma); conta solo nell'esito. +""" +import csv + +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from src.backend.models.orm.measurement import Measurement +from src.backend.models.orm.user import User +from src.backend.services import measurement_service +from src.backend.tests.conftest import auth_headers, create_test_recipe +from src.backend.tests.test_statistics import _seed_measurements + + +async def _recipe_with_nan(client, db_session, tec_user): + """Una ricetta con tre misure numeriche e una NaN (value NULL, fail).""" + recipe = await create_test_recipe(db_session, tec_user.id) + subtask_id, version_id = await _seed_measurements( + client, tec_user, recipe.id, [10.0, 10.1, 9.9], + ) + nan = await measurement_service.save_measurement( + db_session, subtask_id=subtask_id, version_id=version_id, + measured_by=tec_user.id, value=None, input_method="camera", + enforce_tolerance_gate=False, + ) + await db_session.commit() + return recipe, subtask_id, version_id, nan + + +async def test_save_measurement_none_is_fail_without_deviation( + client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User, +): + _, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user) + assert nan.value is None + assert nan.pass_fail == "fail" + assert nan.deviation is None + + +async def test_manual_create_still_requires_a_value( + client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User, +): + """Solo il percorso visione puo' produrre NaN: la creazione manuale resta float.""" + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + subtask_id, version_id = await _seed_measurements( + client, measurement_tec_user, recipe.id, [], + ) + resp = await client.post( + "/api/measurements/", headers=auth_headers(measurement_tec_user), + json={"subtask_id": subtask_id, "version_id": version_id, "value": None}, + ) + assert resp.status_code == 422 + + +async def test_statistics_skip_the_nan_measurement( + client: AsyncClient, db_session: AsyncSession, + measurement_tec_user: User, metrologist_user: User, +): + recipe, subtask_id, _, _ = await _recipe_with_nan( + client, db_session, measurement_tec_user, + ) + params = {"recipe_id": recipe.id, "subtask_id": subtask_id} + headers = auth_headers(metrologist_user) + + cap = await client.get("/api/statistics/capability", headers=headers, params=params) + assert cap.status_code == 200 + assert cap.json()["n"] == 3 + assert abs(cap.json()["mean"] - 10.0) < 1e-9 + + chart = await client.get("/api/statistics/control-chart", headers=headers, params=params) + assert chart.status_code == 200 + assert len(chart.json()["values"]) == 3 + + hist = await client.get("/api/statistics/histogram", headers=headers, params=params) + assert hist.status_code == 200 + + summary = await client.get( + "/api/statistics/summary", headers=headers, params={"recipe_id": recipe.id}, + ) + assert summary.status_code == 200 + + +async def test_measurement_list_returns_null_value( + client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User, +): + _, _, version_id, nan = await _recipe_with_nan(client, db_session, measurement_tec_user) + resp = await client.get( + "/api/measurements/", headers=auth_headers(measurement_tec_user), + params={"version_id": version_id, "per_page": 50}, + ) + assert resp.status_code == 200 + rows = {m["id"]: m for m in resp.json()["items"]} + assert rows[nan.id]["value"] is None + assert rows[nan.id]["pass_fail"] == "fail" + assert rows[nan.id]["deviation"] is None + + +async def test_csv_export_writes_nan( + client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User, +): + _, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user) + resp = await client.get( + "/api/measurements/export/csv", headers=auth_headers(measurement_tec_user), + ) + assert resp.status_code == 200 + rows = list(csv.reader(resp.text.splitlines())) + header = rows[0] + by_id = {r[header.index("id")]: r for r in rows[1:]} + row = by_id[str(nan.id)] + assert row[header.index("value")] == "NaN" + assert row[header.index("pass_fail")] == "fail" + assert row[header.index("deviation")] == "" diff --git a/src/backend/tests/test_vision_execute.py b/src/backend/tests/test_vision_execute.py index 2073bf9..49b3d6b 100644 --- a/src/backend/tests/test_vision_execute.py +++ b/src/backend/tests/test_vision_execute.py @@ -134,72 +134,124 @@ async def test_the_execution_records_which_engine_produced_it( assert result.graph_snapshot == task.vision_json +# #118: una quota che il grafo non produce non e' piu' un 422: e' NaN (value +# NULL), `fail`, salvata con le altre. +async def _assert_nan_saved(client, db_session, user, task, first, second): + response = await client.post( + "/api/vision/execute", + headers=auth_headers(user), + files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id)}, + ) + assert response.status_code == 200 + body = response.json() + assert [m["subtask_id"] for m in body["measurements"]] == [first.id, second.id] + assert body["measurements"][0]["value"] is None + assert body["measurements"][0]["pass_fail"] == "fail" + assert body["measurements"][1]["value"] == GOOD + rows = (await db_session.execute( + select(Measurement).order_by(Measurement.subtask_id) + )).scalars().all() + assert len(rows) == 2 + assert rows[0].value is None + assert rows[0].pass_fail == "fail" + assert rows[0].deviation is None + assert rows[0].vision_result_id is not None + assert rows[1].value is not None and rows[1].pass_fail == "pass" + + @pytest.mark.asyncio -async def test_a_missing_output_names_itself( +async def test_a_missing_output_is_saved_as_nan( client, db_session, measurement_tec_user, worker_says, ): - """No half measurement: if an output is missing, the error names it.""" + """#118: output assente -> 200, misura NULL/fail, le altre quote salvate.""" recipe = await create_test_recipe(db_session, measurement_tec_user.id) - version, task, subtask = await recipe_parts(db_session, recipe.id) - task.task_type = "camera_measure" - task.vision_json = {"schema_version": 1, "name": "p", "tools": []} - subtask.vision_output = "t1.diameter" - await db_session.commit() - worker_says({"t1.radius": 5.0}) + version, task, first, second = await two_vision_quotes(db_session, recipe.id) + worker_says({"t1.radius": GOOD}, failures=[{"tool_name": "arco_tr"}]) + await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second) + + +@pytest.mark.asyncio +async def test_all_outputs_missing_still_saves_every_quote( + client, db_session, measurement_tec_user, worker_says, +): + """#118: anche senza nessuna uscita, una misura NaN per quota.""" + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task, first, second = await two_vision_quotes(db_session, recipe.id) + worker_says({}) response = await client.post( "/api/vision/execute", headers=auth_headers(measurement_tec_user), files={"image": ("p.png", an_image(), "image/png")}, - data={"task_id": str(task.id), "lot_number": "L1"}, + data={"task_id": str(task.id)}, ) - assert response.status_code == 422 - assert "t1.diameter" in response.json()["detail"] - assert (await db_session.execute(select(Measurement))).scalars().all() == [] + assert response.status_code == 200 + assert [m["value"] for m in response.json()["measurements"]] == [None, None] + rows = (await db_session.execute(select(Measurement))).scalars().all() + assert len(rows) == 2 and all(r.value is None and r.pass_fail == "fail" for r in rows) @pytest.mark.asyncio -async def test_a_null_output_counts_as_not_produced( +async def test_a_null_output_is_saved_as_nan( client, db_session, measurement_tec_user, worker_says, ): - """A worker answering with the key present but null (not omitted) must not - reach float(None) as a bare TypeError - it is "not produced", same as a - missing key (#16).""" + """Chiave presente ma null (non omessa): NaN, non TypeError (#16, #118).""" recipe = await create_test_recipe(db_session, measurement_tec_user.id) version, task, first, second = await two_vision_quotes(db_session, recipe.id) worker_says({"t1.diameter": None, "t1.radius": GOOD}) - - response = await client.post( - "/api/vision/execute", - headers=auth_headers(measurement_tec_user), - files={"image": ("p.png", an_image(), "image/png")}, - data={"task_id": str(task.id)}, - ) - - assert response.status_code == 422 - assert "t1.diameter" in response.json()["detail"] - assert (await db_session.execute(select(Measurement))).scalars().all() == [] + await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second) @pytest.mark.asyncio -async def test_a_non_finite_output_counts_as_not_produced( +async def test_a_non_finite_output_is_saved_as_nan( client, db_session, measurement_tec_user, worker_says, ): - """NaN/inf must not become a saved measurement (#16).""" + """NaN/inf non diventano un valore salvato: diventano NaN (NULL) (#16, #118).""" recipe = await create_test_recipe(db_session, measurement_tec_user.id) version, task, first, second = await two_vision_quotes(db_session, recipe.id) worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD}) + await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second) + + +@pytest.mark.asyncio +async def test_a_nan_quote_still_blocks_the_next_acquisition( + client, db_session, measurement_tec_user, worker_says, +): + """#118: la quota NaN e' un fail come gli altri, il blocco di tolleranza resta.""" + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task, first, second = await two_vision_quotes(db_session, recipe.id) + worker_says({"t1.radius": GOOD}) + await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second) + + blocking = await measurement_service.pending_authorisation( + db_session, version.id, measurement_tec_user.id, + ) + assert blocking is not None and blocking.subtask_id == first.id and blocking.value is None + + +@pytest.mark.asyncio +async def test_check_lists_a_missing_quote_as_nan( + client, db_session, measurement_tec_user, worker_says, +): + """#118: check -> una voce per quota, la non prodotta e' value null / fail.""" + recipe = await create_test_recipe(db_session, measurement_tec_user.id) + version, task, first, second = await two_vision_quotes(db_session, recipe.id) + worker_says({"t1.radius": GOOD}) response = await client.post( - "/api/vision/execute", + "/api/vision/check", headers=auth_headers(measurement_tec_user), files={"image": ("p.png", an_image(), "image/png")}, data={"task_id": str(task.id)}, ) - assert response.status_code == 422 - assert "t1.diameter" in response.json()["detail"] + assert response.status_code == 200 + assert response.json()["measurements"] == [ + {"subtask_id": first.id, "value": None, "pass_fail": "fail"}, + {"subtask_id": second.id, "value": GOOD, "pass_fail": "pass"}, + ] assert (await db_session.execute(select(Measurement))).scalars().all() == []