"""Test della calibrazione dell'acquisitore (#83): logica pura e rotte di camera_service. Uso: dalla radice, `uv run --python 3.13 --extra vision-worker --extra dev --with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q` (o il venv di VisionSuite come per test_camera_service.py). """ import base64 import importlib.util import json from pathlib import Path import cv2 import numpy as np import pytest from fastapi.testclient import TestClient SCRIPTS = Path(__file__).resolve().parents[1] MODULO = SCRIPTS / "camera_service.py" def _carica(): spec = importlib.util.spec_from_file_location("camera_service", MODULO) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = 60) -> np.ndarray: """nx x ny quadrati => (nx-1) x (ny-1) angoli interni, su fondo bianco.""" img = np.full((ny * square_px + 2 * margin, nx * square_px + 2 * margin), 255, np.uint8) for j in range(ny): for i in range(nx): if (i + j) % 2 == 0: y, x = margin + j * square_px, margin + i * square_px img[y:y + square_px, x:x + square_px] = 0 return img def _png(img: np.ndarray) -> bytes: ok, buf = cv2.imencode(".png", img) assert ok return buf.tobytes() @pytest.fixture def cartella(tmp_path): # la sorgente camera:folder fa il loop su una scacchiera: il grab la trova cartella = tmp_path / "immagini" cartella.mkdir() cv2.imwrite(str(cartella / "scacchiera.png"), una_scacchiera()) return cartella @pytest.fixture def client(cartella, tmp_path): mod = _carica() with TestClient(mod.create_app(cartella, config_dir=tmp_path / "config")) as c: c.config = tmp_path / "config" yield c CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"} CHIAVI_C1 = {"source", "px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows", "square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"} NON_IMMAGINE = b"questo non e una immagine" # ---- logica pura def test_scacchiera_sintetica_da_la_scala(): _carica() # mette scripts/ in sys.path import calibrazione r = calibrazione.calibra_scacchiera(una_scacchiera(), 9, 6, 2.0) assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005) assert r["mm_per_px"] == pytest.approx(0.05, rel=0.005) assert r["rms_px"] < 0.5 assert r["n_corners"] == 54 assert (r["image_width"], r["image_height"]) == (520, 400) assert r["scale_spread_pct"] < 1.0 assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n" def test_immagine_bianca_solleva(): _carica() import calibrazione with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"): calibrazione.calibra_scacchiera(np.full((300, 300), 255, np.uint8), 9, 6, 2.0) @pytest.mark.parametrize("cols,rows,quadrato", [(1, 6, 2.0), (9, 6, 0.0), (9, 6, -1.0)]) def test_parametri_invalidi_sollevano(cols, rows, quadrato): _carica() import calibrazione with pytest.raises(calibrazione.CalibrazioneError): calibrazione.calibra_scacchiera(una_scacchiera(), cols, rows, quadrato) # ---- rotte def test_c1_non_calibrata_404(client): r = client.get("/camera/calibration", params={"source": "camera:folder"}) assert r.status_code == 404 assert r.json()["detail"] == "sorgente non calibrata" def test_c1_senza_source_400(client): assert client.get("/camera/calibration").status_code == 400 def test_c2_immagine_caricata_senza_salvare(client): r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI}, files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) assert r.status_code == 200, r.text corpo = r.json() assert CHIAVI_C1 | {"overlay_png", "saved"} == set(corpo) assert corpo["saved"] is False assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005) # senza save non si scrive nulla assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404 assert not client.config.exists() or not list(client.config.glob("*.json")) def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client): # nessun file: grab da camera:folder (la scacchiera in cartella), con save r = client.post("/camera/calibrate", data={"source": "camera:folder", "save": "true", **CAMPI}) assert r.status_code == 200, r.text assert r.json()["saved"] is True r1 = client.get("/camera/calibration", params={"source": "camera:folder"}) assert r1.status_code == 200 assert set(r1.json()) == CHIAVI_C1 # nessun overlay nel file salvato assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005) assert r1.json()["source"] == "camera:folder" file_salvati = list(client.config.glob("*.json")) assert len(file_salvati) == 1 assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9 assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204 assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404 # cancellare di nuovo e' idempotente assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204 def test_c2_scacchiera_non_trovata_422(client): bianca = np.full((300, 300), 255, np.uint8) r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI}, files={"image": ("b.png", _png(bianca), "image/png")}) assert r.status_code == 422 assert "non trovata" in r.json()["detail"] def test_c2_parametri_invalidi_422(client): r = client.post("/camera/calibrate", data={"source": "camera:folder", "cols": "1", "rows": "6", "square_mm": "2"}, files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) assert r.status_code == 422 def test_c2_immagine_illeggibile_422(client): r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI}, files={"image": ("x.png", NON_IMMAGINE, "image/png")}) assert r.status_code == 422 def test_c2_errori_camera_come_grab(client): r = client.post("/camera/calibrate", data={"source": "camera:non-esiste", **CAMPI}) assert r.status_code == 404 def test_due_sorgenti_hanno_file_diversi(client): for nome in ("camera:a b", "camera:a_b"): r = client.post("/camera/calibrate", data={"source": nome, "save": "true", **CAMPI}, files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) assert r.status_code == 200 assert len(list(client.config.glob("*.json"))) == 2 def test_c4_pagina_html(client): r = client.get("/calibrazione") assert r.status_code == 200 assert r.headers["content-type"].startswith("text/html") assert "Acquisisci e calcola" in r.text assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN