3870cc3b0f
VisionReferenceImage (migration 013, chained after 012_vision_results): task_id, path, station_id, device_code, calibration_snapshot, engine_version, note, expected_json, acquired_at. station_id/device_code/ calibration_snapshot/engine_version/expected_json stay nullable - nessun endpoint di questo task li popola ancora, arrivano coi Piani 1b/2/4. save_reference_image salva sotto settings.upload_path (non upload_dir, come production_export_service). preview esegue il grafo e non salva niente - comporre non e' misurare, lo asserisce il test. POST /api/vision/reference-images e /api/vision/preview su require_maker (non require_measurement_tec di execute): comporre e provare sono lavoro del Maker, non misure. Suite intera su Python 3.11.15 locale (uv run pytest -q, SQLite in-memory per il backend): 1 failed, 373 passed, 4 skipped in 82.35s. Il fallimento e' quello preesistente e non correlato di test_no_first_party_script_calls_out (fabric-debug.js locale, git-ignored) - 371 passed prima di questo task, +2 qui. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
111 lines
3.9 KiB
Python
111 lines
3.9 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) -> 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() == []
|