"""#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]