"""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_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