From ad6ce5d11da8b9d168696c70080d8808a1f9ff56 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Fri, 23 Oct 2026 22:44:15 +0200 Subject: [PATCH] Backend: ricetta DXF compare da camera e calibrazione letta dall'acquisitore (#83) Co-Authored-By: Claude Sonnet 5.5 --- src/backend/api/routers/vision.py | 117 ++++++- src/backend/services/vision_service.py | 185 +++++++++- src/backend/tests/test_vision_dxf_compare.py | 338 +++++++++++++++++++ 3 files changed, 624 insertions(+), 16 deletions(-) create mode 100644 src/backend/tests/test_vision_dxf_compare.py diff --git a/src/backend/api/routers/vision.py b/src/backend/api/routers/vision.py index 94bc95f..e339e4a 100644 --- a/src/backend/api/routers/vision.py +++ b/src/backend/api/routers/vision.py @@ -4,8 +4,11 @@ It does not know, and must not know, whether the server or the station computed the numbers: that is what lets a station's configuration change without touching the frontend or the recipes. """ +import base64 +import binascii import io import json +from typing import Literal from fastapi.responses import FileResponse from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status @@ -26,7 +29,7 @@ from src.backend.models.api.vision import ( from src.backend.models.orm.vision import VisionResult from src.backend.models.orm.task import RecipeTask from src.backend.models.orm.user import User -from src.backend.services import vision_service +from src.backend.services import station_service, vision_service router = APIRouter(prefix="/api/vision", tags=["vision"]) @@ -185,6 +188,30 @@ def _422(errors: list[str]) -> HTTPException: ) +async def _store_validate_assign( + task: RecipeTask, graph: dict, placed: dict[str, bytes], +) -> tuple[str, dict]: + """Scrive la cartella ricetta, la fa validare dal worker, la assegna al task. + + Condiviso da upload_recipe e dxf-compare con save (#83): stesso + comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check). + """ + # The folder is content-addressed and immutable; writing it before the + # validation is harmless (an orphan folder is never referenced) and the + # worker needs it on disk to validate meta files. + try: + recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed) + except ValueError as exc: + raise _422([str(exc)]) + check = await vision_service.validate_recipe(graph, recipe_dir) + if not check["ok"]: + raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"]) + + task.vision_json = graph + task.vision_recipe_dir = recipe_dir + return recipe_dir, check + + @router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse) async def upload_recipe( task_id: int, @@ -218,19 +245,7 @@ async def upload_recipe( if errors: raise _422(errors) - # The folder is content-addressed and immutable; writing it before the - # validation is harmless (an orphan folder is never referenced) and the - # worker needs it on disk to validate meta files. - try: - recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed) - except ValueError as exc: - raise _422([str(exc)]) - check = await vision_service.validate_recipe(graph, recipe_dir) - if not check["ok"]: - raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"]) - - task.vision_json = graph - task.vision_recipe_dir = recipe_dir + recipe_dir, check = await _store_validate_assign(task, graph, placed) await db.commit() return VisionRecipeUploadResponse( task_id=task.id, @@ -325,3 +340,77 @@ async def preview( ) -> dict: task = await _task_or_404(db, task_id) return await vision_service.preview(db, task, await _read_image(image)) + + +@router.post("/tasks/{task_id}/recipe/dxf-compare") +async def create_dxf_compare_recipe( + task_id: int, + image: UploadFile = File(...), + dxf: UploadFile = File(...), + station_id: int | None = Form(None, gt=0), + px_per_mm: float | None = Form(None, gt=0), + band_mm: float = Form(0.1, gt=0), + min_points_pct: float = Form(95, ge=0, le=100), + polarity: Literal["dark", "light"] = Form("dark"), + save: bool = Form(False), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_maker), +) -> dict: + """Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83). + + La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore). + Con save=false si vede il risultato e non si scrive nulla; con save=true la + ricetta va sul task come con l'upload, e se il task non ha nessuna quota di + visione nasce la quota «Punti in tolleranza». + """ + task = await _task_or_404(db, task_id) + if task.task_type not in vision_service.VISION_TASK_TYPES: + raise _422([f"task type {task.task_type!r} does not run a vision recipe"]) + + station = None + if station_id is not None: + station = await station_service.get_station(db, station_id) # 404 se non esiste + if px_per_mm is None: + calibration, reason = await vision_service.fetch_station_calibration(station) + if calibration is None: + # irraggiungibile e non calibrata sono due guasti diversi per chi lo legge + raise _422([ + "acquisitore della stazione non raggiungibile" + if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata" + ]) + px_per_mm = float(calibration["px_per_mm"]) + + image_bytes = await _read_image(image) + dxf_bytes = await dxf.read() + if not validate_file_size(len(dxf_bytes)): + raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"]) + + body = await vision_service.dxf_compare_recipe( + image_bytes, dxf_bytes, + px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct, + polarity=polarity, + worker_url=(station.vision_worker_url or None) if station is not None else None, + ) + # i file stanno nella ricetta salvata, non nella risposta al client + files = body.pop("files") + response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None} + if not save: + return response + + uploads: list[tuple[str, bytes]] = [] + for name, encoded in files.items(): + try: + uploads.append((name, base64.b64decode(encoded, validate=True))) + except (binascii.Error, ValueError, TypeError): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"the vision worker's answer was malformed: file {name!r} is not base64", + ) + placed, errors = vision_service.match_recipe_files(body["graph"], uploads) + if errors: + raise _422(errors) + recipe_dir, check = await _store_validate_assign(task, body["graph"], placed) + await vision_service.ensure_points_quote(db, task, min_points_pct) + await db.commit() + response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"]) + return response diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index 7af7b2f..87ea2ac 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -7,6 +7,7 @@ from __future__ import annotations import hashlib import json +import logging import math from pathlib import Path from uuid import uuid4 @@ -24,6 +25,8 @@ from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.models.orm.vision import VisionReferenceImage, VisionResult from src.backend.services import measurement_service +logger = logging.getLogger(__name__) + # Worker responses whose status and reason are forwarded as-is rather than # flattened into a generic 502 - see `_call_worker`. _FORWARDED_WORKER_STATUSES = frozenset({ @@ -140,6 +143,136 @@ async def _call_worker( return report +async def call_worker_multipart( + path: str, + *, + files: dict, + data: dict, + worker_url: str | None = None, + timeout: float = 120.0, +) -> dict: + """POST multipart generico al worker (#83): /recipes/dxf-compare. + + Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul + trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si + inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale. + """ + base = (worker_url or settings.vision_worker_url).rstrip("/") + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post(f"{base}{path}", files=files, data=data) + except httpx.TimeoutException as exc: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail=f"the vision worker did not answer in time: {exc} ({base})", + ) from exc + except (httpx.TransportError, httpx.HTTPError) as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"the vision worker is unreachable: {exc} ({base})", + ) from exc + + if response.status_code in _FORWARDED_WORKER_STATUSES: + try: + detail = response.json().get("detail", response.text) + except ValueError: + detail = response.text + raise HTTPException(status_code=response.status_code, detail=detail) + if response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"the vision worker refused the job: {response.text}", + ) + try: + body = response.json() + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"the vision worker's answer was malformed: not valid JSON ({exc})", + ) from exc + if not isinstance(body, dict): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="the vision worker's answer was malformed: expected a JSON object", + ) + return body + + +async def dxf_compare_recipe( + image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float, + min_points_pct: float, polarity: str, worker_url: str | None = None, +) -> dict: + """W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test.""" + body = await call_worker_multipart( + "/recipes/dxf-compare", + files={ + "image": ("frame.png", image_bytes, "image/png"), + "dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"), + }, + data={ + "px_per_mm": str(px_per_mm), "band_mm": str(band_mm), + "min_points_pct": str(min_points_pct), "polarity": polarity, + }, + worker_url=worker_url, + ) + # forma minima del contratto W2: senza graph e files non c'e' nulla da salvare + if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict): + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="the vision worker's answer was malformed: missing graph or files", + ) + return body + + +# Stati di fetch_station_calibration (#83) +CAL_OK = "ok" +CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source +CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile) +CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde + + +async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]: + """Legge la calibrazione dall'acquisitore della stazione (C1, #83). + + GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione + vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok") + oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile». + Un px_per_mm non finito o <= 0 conta come non calibrata. + """ + if station is None or not station.camera_url or not station.camera_source: + return None, CAL_NO_CAMERA + url = f"{station.camera_url.rstrip('/')}/camera/calibration" + try: + # timeout breve: e' una lettura di servizio, non deve bloccare l'execute + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(url, params={"source": station.camera_source}) + except httpx.HTTPError as exc: + logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc) + return None, CAL_UNREACHABLE + if response.status_code == status.HTTP_404_NOT_FOUND: + return None, CAL_NOT_CALIBRATED + if response.status_code != 200: + logger.warning( + "acquisitore della stazione %s: HTTP %s", station.code, response.status_code, + ) + return None, CAL_UNREACHABLE + try: + body = response.json() + except ValueError: + logger.warning("acquisitore della stazione %s: risposta non JSON", station.code) + return None, CAL_UNREACHABLE + px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None + if not (_is_measurable(px_per_mm) and px_per_mm > 0): + return None, CAL_NOT_CALIBRATED + return body, CAL_OK + + +async def get_station_calibration(station: Station | None) -> dict | None: + """La calibrazione C1 della stazione, o None in ogni altro caso (#83).""" + calibration, _ = await fetch_station_calibration(station) + return calibration + + async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict: """Ask the worker's /validate whether a v2 recipe is loadable (#46). @@ -365,11 +498,14 @@ async def execute_task( ) # Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale station_url = None + # Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e' + calibration = None if run is not None: station = (await db.execute( select(Station).where(Station.id == run.station_id) )).scalar_one_or_none() station_url = (station.vision_worker_url or None) if station is not None else None + calibration = await get_station_calibration(station) if not task.vision_json: raise HTTPException( @@ -419,11 +555,15 @@ async def execute_task( ), ) - # Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si - # passa `calibration`, la ricetta porta la sua (#46). + # La calibrazione viene dall'acquisitore della stazione (#83); se non c'e', + # non si passa `calibration` e la ricetta porta la sua (#46). + worker_kwargs: dict = {} + if calibration is not None: + worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])} report = await _call_worker( image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir, worker_url=station_url, # #45 + **worker_kwargs, ) outputs = report["outputs"] @@ -457,6 +597,8 @@ async def execute_task( station_id=run.station_id if run is not None else None, worker_url=(station_url or settings.vision_worker_url).rstrip("/"), graph_snapshot=task.vision_json, + # copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato + calibration_snapshot=calibration, duration_ms=report.get("duration_ms"), ) db.add(result) @@ -601,3 +743,42 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic return await _call_worker( image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir, ) + + +async def ensure_points_quote( + db: AsyncSession, task: RecipeTask, min_points_pct: float, +) -> bool: + """Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83). + + Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca. + Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router + dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata. + """ + from src.backend.services import recipe_service # import locale: evita cicli + + existing = (await db.execute( + select(RecipeSubtask.id) + .where(RecipeSubtask.task_id == task.id) + .where(RecipeSubtask.vision_output.is_not(None)) + .limit(1) + )).scalar_one_or_none() + if existing is not None: + return False + if await recipe_service.version_has_measurements(db, task.version_id): + return False + markers = (await db.execute( + select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id) + )).scalars().all() + db.add(RecipeSubtask( + task_id=task.id, + marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker) + description="Punti in tolleranza", + nominal=100, + # limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct + ltl=min_points_pct, + utl=100, + unit="%", + vision_output="dxf.points_in_pct", + )) + await db.flush() + return True diff --git a/src/backend/tests/test_vision_dxf_compare.py b/src/backend/tests/test_vision_dxf_compare.py new file mode 100644 index 0000000..b3803e8 --- /dev/null +++ b/src/backend/tests/test_vision_dxf_compare.py @@ -0,0 +1,338 @@ +"""#83: ricetta DXF compare da camera e calibrazione letta dall'acquisitore. + +Il worker (W2) e l'acquisitore (C1) non girano nei test: si fingono, come +fanno gli altri test di visione. La calibrazione vive nell'acquisitore esterno, +TMF la legge via GET {camera_url}/camera/calibration. +""" +import base64 +import json + +import httpx +import pytest +from sqlalchemy import select + +from src.backend.config import settings +from src.backend.models.orm.station import Station +from src.backend.models.orm.task import RecipeSubtask +from src.backend.models.orm.vision import VisionResult +from src.backend.services import vision_service +from src.backend.tests.conftest import auth_headers +from src.backend.tests.test_vision_recipe_upload import an_image, make_task +from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls) + _camera_task, _execute, _station_with_run, worker_calls, +) + +CAMERA_URL = "http://10.0.0.9:8200" +CAL = { + "source": "camera:A1", "px_per_mm": 12.5, "mm_per_px": 0.08, "rms_px": 0.2, + "n_corners": 40, "cols": 8, "rows": 5, "square_mm": 5.0, "image_width": 640, + "image_height": 480, "scale_spread_pct": 0.1, "calibrated_at": "2026-10-23T10:00:00", +} +GRAPH = { + "schema_version": 2, "name": "dxf-compare", "tools": [], + "meta": {"dxf": {"disegno": {"path": "disegno.dxf"}}, "calibration": {"path": "calibrazione.json"}}, +} +OUTPUTS = [{"key": "dxf.points_in_pct", "quantity": "ratio", "unit": "%"}] + + +def _b64(data: bytes) -> str: + return base64.b64encode(data).decode() + + +@pytest.fixture(autouse=True) +def redirect_uploads(monkeypatch, tmp_path): + # upload_path = parents[2] / upload_dir: un assoluto sostituisce la base + monkeypatch.setattr(settings, "upload_dir", str(tmp_path)) + + +@pytest.fixture +def w2(monkeypatch): + """Finto worker W2: ricorda le chiamate e risponde col corpo del contratto.""" + calls: list[dict] = [] + + async def fake(image_bytes, dxf_bytes, **kwargs): + calls.append({"dxf": dxf_bytes, **kwargs}) + return { + "graph": GRAPH, + "files": { + "disegno.dxf": _b64(dxf_bytes), + "calibrazione.json": _b64(json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()), + }, + "outputs": {"dxf.points_in_pct": 99.0}, + "overlay": [], "outcome": "pass", + } + + monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake) + return calls + + +@pytest.fixture +def validate_ok(monkeypatch): + async def fake(graph, recipe_dir): + return {"ok": True, "errors": [], "outputs": OUTPUTS} + monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake) + + +@pytest.fixture +def acquisitore(monkeypatch): + """Finto C1: `state['cal']` = dict (200), None (404), 'down' (irraggiungibile).""" + state: dict = {"cal": CAL, "urls": []} + + class _Resp: + def __init__(self, code, body=None): + self.status_code, self._body, self.text = code, body, "" + + def json(self): + return self._body + + class _Client: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def get(self, url, params=None): + state["urls"].append((url, params)) + if state["cal"] == "down": + raise httpx.ConnectError("giu'") + if state["cal"] is None: + return _Resp(404) + return _Resp(200, state["cal"]) + + monkeypatch.setattr(httpx, "AsyncClient", _Client) + return state + + +async def _station(db_session, user, *, camera=True, worker_url=None, code="ST-C"): + station = Station( + code=code, name="Stazione C", active=True, created_by=user.id, + vision_worker_url=worker_url, + camera_url=CAMERA_URL if camera else None, + camera_source="camera:A1" if camera else None, + ) + db_session.add(station) + await db_session.commit() + await db_session.refresh(station) + return station + + +def _post(client, user, task, *, save=False, **data): + return client.post( + f"/api/vision/tasks/{task.id}/recipe/dxf-compare", + headers=auth_headers(user), + files={ + "image": ("p.png", an_image(), "image/png"), + "dxf": ("d.dxf", b"0\nEOF", "application/octet-stream"), + }, + data={"save": str(save).lower(), **{k: str(v) for k, v in data.items() if v is not None}}, + ) + + +# --- lettura della calibrazione dall'acquisitore (C1) ------------------------- + +@pytest.mark.asyncio +async def test_fetch_calibration_ok(db_session, maker_user, acquisitore): + station = await _station(db_session, maker_user) + cal, reason = await vision_service.fetch_station_calibration(station) + assert (cal, reason) == (CAL, "ok") + assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})] + + +@pytest.mark.asyncio +async def test_fetch_calibration_reasons(db_session, maker_user, acquisitore): + station = await _station(db_session, maker_user) + acquisitore["cal"] = None + assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated") + acquisitore["cal"] = "down" + assert await vision_service.fetch_station_calibration(station) == (None, "unreachable") + assert await vision_service.get_station_calibration(station) is None + acquisitore["cal"] = {**CAL, "px_per_mm": 0} # valore inutilizzabile = non calibrata + assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated") + no_camera = await _station(db_session, maker_user, camera=False, code="ST-N") + assert await vision_service.fetch_station_calibration(no_camera) == (None, "no_camera") + assert await vision_service.fetch_station_calibration(None) == (None, "no_camera") + + +# --- B3: POST /api/vision/tasks/{id}/recipe/dxf-compare ----------------------- + +@pytest.mark.asyncio +async def test_dxf_compare_preview_writes_nothing( + client, db_session, maker_user, w2, acquisitore, +): + task, subtask = await make_task(db_session, maker_user) + station = await _station(db_session, maker_user, worker_url="http://10.0.0.7:8100") + + resp = await _post(client, maker_user, task, station_id=station.id, band_mm=0.2, + min_points_pct=90, polarity="light") + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["saved"] is False and body["recipe_dir"] is None and body["outputs"] is None + assert "files" not in body["result"] and body["result"]["graph"] == GRAPH + # px_per_mm dall'acquisitore, parametri e worker della stazione + assert w2[0]["px_per_mm"] == 12.5 and w2[0]["band_mm"] == 0.2 + assert w2[0]["min_points_pct"] == 90 and w2[0]["polarity"] == "light" + assert w2[0]["worker_url"] == "http://10.0.0.7:8100" + await db_session.refresh(task) + assert task.vision_json is None and task.vision_recipe_dir is None + quotes = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all() + assert [q.id for q in quotes] == [subtask.id] # nessuna quota nuova + assert not (settings.upload_path / "vision" / "recipes").exists() + + +@pytest.mark.asyncio +async def test_dxf_compare_save_stores_recipe_and_quote_once( + client, db_session, maker_user, w2, validate_ok, +): + task, _ = await make_task(db_session, maker_user) + + resp = await _post(client, maker_user, task, save=True, px_per_mm=10, min_points_pct=95) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["saved"] is True and body["outputs"] == OUTPUTS + assert "files" not in body["result"] + folder = settings.upload_path / body["recipe_dir"] + assert (folder / "disegno.dxf").read_bytes() == b"0\nEOF" + assert json.loads((folder / "calibrazione.json").read_text()) == {"px_per_mm": 10.0} + await db_session.refresh(task) + assert task.vision_json == GRAPH and task.vision_recipe_dir == body["recipe_dir"] + + quotes = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct") + .where(RecipeSubtask.task_id == task.id))).scalars().all() + assert len(quotes) == 1 + q = quotes[0] + assert q.description == "Punti in tolleranza" + assert (float(q.nominal), float(q.ltl), float(q.utl)) == (100.0, 95.0, 100.0) + + # secondo salvataggio: la quota non si duplica + task_id = task.id # dopo expire_all l'attributo scaduto non si rilegge in async + again = await _post(client, maker_user, task, save=True, px_per_mm=10) + assert again.status_code == 200 + db_session.expire_all() + count = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.vision_output.is_not(None)) + .where(RecipeSubtask.task_id == task_id))).scalars().all() + assert len(count) == 1 + + +@pytest.mark.asyncio +async def test_dxf_compare_keeps_an_existing_vision_quote( + client, db_session, maker_user, w2, validate_ok, +): + task, subtask = await make_task(db_session, maker_user) + subtask.vision_output = "t1.diameter" + await db_session.commit() + + resp = await _post(client, maker_user, task, save=True, px_per_mm=10) + + assert resp.status_code == 200, resp.text + quotes = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all() + assert len(quotes) == 1 # niente quota in piu' + + +@pytest.mark.asyncio +async def test_dxf_compare_uncalibrated_station_is_422( + client, db_session, maker_user, w2, acquisitore, +): + task, _ = await make_task(db_session, maker_user) + station = await _station(db_session, maker_user) + acquisitore["cal"] = None + resp = await _post(client, maker_user, task, station_id=station.id) + assert resp.status_code == 422 + assert resp.json()["detail"] == ["stazione non calibrata"] + assert w2 == [] + + +@pytest.mark.asyncio +async def test_dxf_compare_unreachable_acquirer_is_422_with_its_own_message( + client, db_session, maker_user, w2, acquisitore, +): + task, _ = await make_task(db_session, maker_user) + station = await _station(db_session, maker_user) + acquisitore["cal"] = "down" + resp = await _post(client, maker_user, task, station_id=station.id) + assert resp.status_code == 422 + assert resp.json()["detail"] == ["acquisitore della stazione non raggiungibile"] + + +@pytest.mark.asyncio +async def test_dxf_compare_without_station_nor_scale_is_422(client, db_session, maker_user, w2): + task, _ = await make_task(db_session, maker_user) + resp = await _post(client, maker_user, task) + assert resp.status_code == 422 and resp.json()["detail"] == ["stazione non calibrata"] + + +@pytest.mark.asyncio +async def test_dxf_compare_needs_maker(client, db_session, maker_user, measurement_tec_user, w2): + task, _ = await make_task(db_session, maker_user) + resp = await _post(client, measurement_tec_user, task, px_per_mm=10) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_dxf_compare_worker_422_is_forwarded( + client, db_session, maker_user, monkeypatch, +): + from fastapi import HTTPException + + async def refuse(*a, **k): + raise HTTPException(status_code=422, detail="pezzo non trovato") + monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", refuse) + task, _ = await make_task(db_session, maker_user) + resp = await _post(client, maker_user, task, px_per_mm=10) + assert resp.status_code == 422 and resp.json()["detail"] == "pezzo non trovato" + + +# --- execute: calibrazione della stazione ------------------------------------- + +@pytest.mark.asyncio +async def test_execute_passes_station_calibration_and_snapshots_it( + client, db_session, measurement_tec_user, worker_calls, acquisitore, +): + recipe, task = await _camera_task(db_session, measurement_tec_user.id) + station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None) + station.camera_url, station.camera_source = CAMERA_URL, "camera:A1" + await db_session.commit() + + await _execute(client, measurement_tec_user, task, run) + + assert worker_calls[0]["calibration"] == {"px_per_mm": 12.5} + result = (await db_session.execute(select(VisionResult))).scalars().one() + assert result.calibration_snapshot == CAL + + +@pytest.mark.asyncio +async def test_execute_without_calibration_is_unchanged( + client, db_session, measurement_tec_user, worker_calls, acquisitore, +): + recipe, task = await _camera_task(db_session, measurement_tec_user.id) + station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None) + station.camera_url, station.camera_source = CAMERA_URL, "camera:A1" + await db_session.commit() + acquisitore["cal"] = "down" # acquisitore giu': si misura come prima, senza calibrazione + + await _execute(client, measurement_tec_user, task, run) + + assert "calibration" not in worker_calls[0] + result = (await db_session.execute(select(VisionResult))).scalars().one() + assert result.calibration_snapshot is None + + +@pytest.mark.asyncio +async def test_execute_station_without_camera_never_asks( + client, db_session, measurement_tec_user, worker_calls, acquisitore, +): + recipe, task = await _camera_task(db_session, measurement_tec_user.id) + _, run = await _station_with_run(db_session, measurement_tec_user, recipe, None) + + await _execute(client, measurement_tec_user, task, run) + + assert acquisitore["urls"] == [] and "calibration" not in worker_calls[0]