"""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, **kwargs) -> 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_the_file_survives_a_failed_commit_as_nothing( client, db_session, maker_user, monkeypatch, ): """#38.5: the file is written only after a successful flush, and removed if the commit that follows still fails - never an orphan on disk.""" recipe = await create_test_recipe(db_session, maker_user.id) version, task, subtask = await recipe_parts(db_session, recipe.id) await db_session.commit() original_commit = db_session.commit async def boom(*args, **kwargs): raise RuntimeError("simulated commit failure") monkeypatch.setattr(db_session, "commit", boom) try: # The ASGI transport re-raises an unhandled app exception rather # than turning it into a response - the same way it would surface # in production logs. What matters here is what is left on disk. with pytest.raises(RuntimeError, match="simulated commit failure"): 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)}, ) finally: # The db_session fixture commits again on its own teardown - restore # the real commit so that does not also blow up. monkeypatch.setattr(db_session, "commit", original_commit) folder = settings.upload_path / "vision" / "reference" / str(task.id) assert not folder.exists() or list(folder.iterdir()) == [] @pytest.mark.asyncio async def test_reference_images_are_listed_for_their_task( 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() for _ in range(2): 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)}, ) assert response.status_code == 200 listing = await client.get( "/api/vision/reference-images", headers=auth_headers(maker_user), params={"task_id": task.id}, ) assert listing.status_code == 200 body = listing.json() assert len(body) == 2 assert {row["task_id"] for row in body} == {task.id} @pytest.mark.asyncio async def test_deleting_a_reference_image_removes_the_row_and_the_file( 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() created = 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)}, ) reference_id = created.json()["id"] file_path = settings.upload_path / created.json()["path"] assert file_path.exists() response = await client.delete( f"/api/vision/reference-images/{reference_id}", headers=auth_headers(maker_user), ) assert response.status_code == 204 assert (await db_session.execute( select(VisionReferenceImage).where(VisionReferenceImage.id == reference_id) )).scalar_one_or_none() is None assert not file_path.exists() @pytest.mark.asyncio async def test_deleting_an_unknown_reference_image_is_404( client, db_session, maker_user, ): response = await client.delete( "/api/vision/reference-images/999999", headers=auth_headers(maker_user), ) assert response.status_code == 404 @pytest.mark.asyncio async def test_copy_on_write_carries_over_reference_images( client, db_session, maker_user, measurement_tec_user, ): """#38.6: a copy-on-write version must list the source task's reference images (same path, same metadata), and the file is removed only once no row references it any more.""" recipe = await create_test_recipe(db_session, maker_user.id) version, task, subtask = await recipe_parts(db_session, recipe.id) await db_session.commit() created = 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": "orig"}, ) assert created.status_code == 200 old_reference_id = created.json()["id"] path = created.json()["path"] file_path = settings.upload_path / path assert file_path.exists() # A measurement on v1 forces the next PUT onto the copy-on-write path. measured = await client.post( "/api/measurements/", headers=auth_headers(measurement_tec_user), json={"subtask_id": subtask.id, "version_id": version.id, "value": 10.0}, ) assert measured.status_code == 200 resp = await client.put( f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user), json={"change_notes": "Edit after measurement"}, ) assert resp.status_code == 200, resp.text db_session.expunge_all() new_version, new_task, _ = await recipe_parts(db_session, recipe.id) assert new_version.version_number == 2 listing = await client.get( "/api/vision/reference-images", headers=auth_headers(maker_user), params={"task_id": new_task.id}, ) assert listing.status_code == 200 body = listing.json() assert len(body) == 1 assert body[0]["path"] == path assert body[0]["note"] == "orig" new_reference_id = body[0]["id"] assert new_reference_id != old_reference_id # Two rows share the same file: deleting one must leave it in place. del1 = await client.delete( f"/api/vision/reference-images/{old_reference_id}", headers=auth_headers(maker_user), ) assert del1.status_code == 204 assert file_path.exists() # Deleting the last row referencing the path removes the file too. del2 = await client.delete( f"/api/vision/reference-images/{new_reference_id}", headers=auth_headers(maker_user), ) assert del2.status_code == 204 assert not file_path.exists() @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() == []