ad6ce5d11d
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
339 lines
13 KiB
Python
339 lines
13 KiB
Python
"""#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]
|