Files
TieMeasureFlow/src/backend/tests/test_vision_reference.py
T

290 lines
10 KiB
Python

"""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() == []