"""The Maker composes at a desk: image saved, graph tried, no measurement.""" import io import pytest from PIL import Image from sqlalchemy import select from src.backend.config import settings 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 VisionReferenceImage from src.backend.tests.conftest import auth_headers, create_test_recipe 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 @pytest.fixture def worker_says(monkeypatch): def _install(outputs: dict): async def fake_call(image_bytes: bytes, graph: dict) -> dict: return {"outputs": outputs, "failures": [], "engine_version": "b" * 40, "duration_ms": 7} monkeypatch.setattr( "src.backend.services.vision_service._call_worker", fake_call, ) return _install @pytest.fixture(autouse=True) def redirect_uploads(monkeypatch, tmp_path): """Keep reference images out of the real repo tree during tests. `settings.upload_path` is `parents[2] / self.upload_dir`, and pathlib replaces the left operand of `/` when the right one is absolute - so redirecting the bare `upload_dir` setting to pytest's absolute `tmp_path` is enough to redirect the resolved path too. """ monkeypatch.setattr(settings, "upload_dir", str(tmp_path)) @pytest.mark.asyncio async def test_a_reference_image_keeps_its_provenance( client, db_session, maker_user, ): recipe = await create_test_recipe(db_session, maker_user.id) version, task, subtask = await recipe_parts(db_session, recipe.id) await db_session.commit() response = await client.post( "/api/vision/reference-images", headers=auth_headers(maker_user), files={"image": ("ref.png", an_image(), "image/png")}, data={"task_id": str(task.id), "note": "sample part"}, ) assert response.status_code == 200 saved = (await db_session.execute( select(VisionReferenceImage) )).scalars().one() assert saved.task_id == task.id assert saved.note == "sample part" assert saved.acquired_at is not None assert saved.path @pytest.mark.asyncio async def test_preview_runs_the_graph_and_saves_no_measurement( client, db_session, maker_user, worker_says, ): recipe = await create_test_recipe(db_session, maker_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": 42.0}) response = await client.post( "/api/vision/preview", headers=auth_headers(maker_user), files={"image": ("ref.png", an_image(), "image/png")}, data={"task_id": str(task.id)}, ) assert response.status_code == 200 assert response.json()["outputs"]["t1.diameter"] == 42.0 # This is the whole point of this test: composing does not measure. assert (await db_session.execute(select(Measurement))).scalars().all() == []