Visione: quota non prodotta salvata come NaN (value NULL, fail) invece del 422; migrazione 022
Refs #118 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")] == ""
|
||||
@@ -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() == []
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user