Files
TieMeasureFlow/src/backend/tests/test_vision_execute.py
T
Adriano 20957e517d fix(vision): NaN/inf/bool di uno strumento non diventano più una quota (#16)
runner.run_graph esclude bool e NaN/inf dagli output numerici, con il
motivo in RunOutcome.invalid_outputs. vision_service tratta una quota
mappata con valore None/assente/non finito come "non prodotta" (422),
invece di arrivare a float(None) e sollevare TypeError.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:35:53 +02:00

374 lines
14 KiB
Python

"""A quote from a camera is a measurement like any other: same verdict, same gate."""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionResult
from src.backend.services import measurement_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
GOOD = 10.0 # inside every limit of the fixture quote
OUT = 99.0 # far outside
def an_image() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
return buffer.getvalue()
async def recipe_parts(db_session, recipe_id: int):
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
version = (await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
subtask = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
return version, task, subtask
async def two_vision_quotes(db_session, recipe_id: int):
"""The fixture recipe's task, with a second quote mapped to a vision output.
Same shape as `test_out_of_tolerance.py::_two_quotes`, adapted so both
quotes belong to a single camera acquisition instead of two separate
manual readings.
"""
version, task, first = await recipe_parts(db_session, recipe_id)
first.vision_output = "t1.diameter"
second = RecipeSubtask(
task_id=task.id, marker_number=2, description="Second quote",
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
vision_output="t1.radius",
)
db_session.add(second)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
await db_session.commit()
await db_session.refresh(second)
return version, task, first, second
@pytest.fixture
def worker_says(monkeypatch):
"""The worker does not run in tests: its response is faked here."""
def _install(outputs: dict, failures: list | None = None):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
return {
"outputs": outputs,
"failures": failures or [],
"engine_version": "a" * 40,
"duration_ms": 12,
}
monkeypatch.setattr(
"src.backend.services.vision_service._call_worker", fake_call,
)
return _install
@pytest.mark.asyncio
async def test_a_camera_quote_becomes_a_measurement(
client, db_session, measurement_tec_user, worker_says,
):
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"
# The fixture creates the quote with nominal 10 and tight limits around it.
await db_session.commit()
worker_says({"t1.diameter": float(subtask.nominal)})
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"},
)
assert response.status_code == 200
saved = (await db_session.execute(
select(Measurement).where(Measurement.subtask_id == subtask.id)
)).scalars().all()
assert len(saved) == 1
assert saved[0].pass_fail == "pass"
assert saved[0].input_method == "camera"
assert saved[0].vision_result_id is not None
@pytest.mark.asyncio
async def test_the_execution_records_which_engine_produced_it(
client, db_session, measurement_tec_user, worker_says,
):
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.diameter": float(subtask.nominal)})
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"},
)
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.engine_version == "a" * 40
assert result.executed_on == "server"
assert result.graph_snapshot == task.vision_json
@pytest.mark.asyncio
async def test_a_missing_output_names_itself(
client, db_session, measurement_tec_user, worker_says,
):
"""No half measurement: if an output is missing, the error names it."""
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})
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"},
)
assert response.status_code == 422
assert "t1.diameter" in response.json()["detail"]
assert (await db_session.execute(select(Measurement))).scalars().all() == []
@pytest.mark.asyncio
async def test_a_null_output_counts_as_not_produced(
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)."""
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() == []
@pytest.mark.asyncio
async def test_a_non_finite_output_counts_as_not_produced(
client, db_session, measurement_tec_user, worker_says,
):
"""NaN/inf must not become a saved measurement (#16)."""
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})
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() == []
@pytest.mark.asyncio
async def test_a_task_without_a_graph_is_refused(
client, db_session, measurement_tec_user, worker_says,
):
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"
await db_session.commit()
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)},
)
assert response.status_code == 422
assert "graph" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# Ruling R10: the gate is evaluated once per acquisition, not once per quote.
# A camera acquisition produces every quote of the task at the same instant -
# there is no "moving on" between them, so the sequential gate must not fire
# inside the batch. It still has to fire between two separate acquisitions.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_a_non_last_out_of_tolerance_quote_does_not_break_the_batch(
client, db_session, measurement_tec_user, worker_says,
):
"""The quote evaluated first fails; the one after it must still be saved."""
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": OUT, "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 == 200
by_subtask = {
m.subtask_id: m.pass_fail
for m in (await db_session.execute(select(Measurement))).scalars().all()
}
assert by_subtask == {first.id: "fail", second.id: "pass"}
@pytest.mark.asyncio
async def test_a_pending_fail_outside_the_batch_still_blocks(
client, db_session, measurement_tec_user, worker_says,
):
"""A fail left over from something else this camera task does not cover."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
# A third quote on the same task, not part of this acquisition (no vision
# output), already failed and is waiting on a supervisor.
third = RecipeSubtask(
task_id=task.id, marker_number=3, description="Not a vision quote",
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
)
db_session.add(third)
await db_session.commit()
await db_session.refresh(third)
await measurement_service.save_measurement(
db_session, subtask_id=third.id, version_id=version.id,
measured_by=measurement_tec_user.id, value=OUT,
)
await db_session.commit()
worker_says({"t1.diameter": GOOD, "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 == 409
assert "supervisor" in response.json()["detail"].lower()
assert (await db_session.execute(
select(Measurement).where(Measurement.subtask_id.in_([first.id, second.id]))
)).scalars().all() == []
@pytest.mark.asyncio
async def test_a_pending_fail_blocks_before_the_worker_is_called(
client, db_session, measurement_tec_user, monkeypatch,
):
"""M3: a blocked operator must not burn a whole vision execution to learn
they are blocked. The gate is checked before the worker runs, not after."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
third = RecipeSubtask(
task_id=task.id, marker_number=3, description="Not a vision quote",
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
)
db_session.add(third)
await db_session.commit()
await db_session.refresh(third)
await measurement_service.save_measurement(
db_session, subtask_id=third.id, version_id=version.id,
measured_by=measurement_tec_user.id, value=OUT,
)
await db_session.commit()
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict:
raise AssertionError("the worker must not run while the operator is blocked")
monkeypatch.setattr(
"src.backend.services.vision_service._call_worker", worker_must_not_be_called,
)
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 == 409
assert "supervisor" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_a_new_acquisition_can_remeasure_its_own_pending_quote(
client, db_session, measurement_tec_user, worker_says,
):
"""Re-shooting the same task is the way out, same as re-measuring by hand."""
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": OUT, "t1.radius": GOOD})
first_shot = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p1.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert first_shot.status_code == 200
# first.id is now pending authorisation - but it belongs to this same
# task, so a new acquisition of it is not "moving on".
worker_says({"t1.diameter": GOOD, "t1.radius": GOOD})
second_shot = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p2.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert second_shot.status_code == 200
readings = (await db_session.execute(
select(Measurement)
.where(Measurement.subtask_id == first.id)
.order_by(Measurement.id)
)).scalars().all()
assert [m.pass_fail for m in readings] == ["fail", "pass"]
assert await measurement_service.pending_authorisation(
db_session, version.id, measurement_tec_user.id,
) is None