ee0f65ed39
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
290 lines
10 KiB
Python
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() == []
|