diff --git a/src/backend/api/routers/tasks.py b/src/backend/api/routers/tasks.py index 8a23988..bfac75c 100644 --- a/src/backend/api/routers/tasks.py +++ b/src/backend/api/routers/tasks.py @@ -250,6 +250,8 @@ async def create_task( file_type=data.file_type, annotations_json=data.annotations_json, vision_json=data.vision_json, + # #97: camera e modo di analisi (dict gia' validato dal modello) + acquisition_json=data.acquisition_json.model_dump() if data.acquisition_json else None, # #53 #55: ciclo (default dal tipo), chiusura, campi extra in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type), at_close=data.at_close, diff --git a/src/backend/api/routers/vision.py b/src/backend/api/routers/vision.py index e339e4a..45f1c7c 100644 --- a/src/backend/api/routers/vision.py +++ b/src/backend/api/routers/vision.py @@ -23,7 +23,7 @@ from src.backend.api.routers.files import ( from src.backend.config import settings from src.backend.database import get_db from src.backend.models.api.vision import ( - VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse, + VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse, VisionRecipeUploadResponse, VisionReferenceImageResponse, ) from src.backend.models.orm.vision import VisionResult @@ -164,6 +164,22 @@ async def execute( ) +@router.post("/check", response_model=VisionCheckResponse) +async def check( + task_id: int = Form(...), + image: UploadFile = File(...), + production_run_id: int | None = Form(None, gt=0), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_measurement_tec), +) -> VisionCheckResponse: + """#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure).""" + task = await _task_or_404(db, task_id) + report = await vision_service.check_task( + db, task, await _read_image(image), production_run_id=production_run_id, + ) + return VisionCheckResponse(**report) + + @router.get("/results/{result_id}/image") async def get_result_image( result_id: int, diff --git a/src/backend/migrations/versions/021_task_acquisition.py b/src/backend/migrations/versions/021_task_acquisition.py new file mode 100644 index 0000000..b298f0e --- /dev/null +++ b/src/backend/migrations/versions/021_task_acquisition.py @@ -0,0 +1,31 @@ +"""recipe_tasks.acquisition_json (#97) + +recipe_tasks.acquisition_json: camera e modo di analisi scelti nel task, +{"camera_url", "camera_source", "modo": "impulsiva" | "in_linea"}; NULL = la +camera della stazione dell'operatore, analisi impulsiva (come prima). + +Revision ID: 021_task_acquisition +Revises: 020_station_camera +Create Date: 2026-10-24 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '021_task_acquisition' +down_revision: Union[str, None] = '020_station_camera' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # batch_alter_table come nelle migrazioni vicine: serve a SQLite + with op.batch_alter_table('recipe_tasks') as batch: + batch.add_column(sa.Column('acquisition_json', sa.JSON(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('recipe_tasks') as batch: + batch.drop_column('acquisition_json') diff --git a/src/backend/models/api/task.py b/src/backend/models/api/task.py index 035c903..5e182e2 100644 --- a/src/backend/models/api/task.py +++ b/src/backend/models/api/task.py @@ -2,8 +2,9 @@ import re from typing import Any, Literal, Optional -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from src.backend.models.api.station import _normalize_http_url from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES # Kept in step with the ORM enum so the two cannot drift apart. @@ -159,6 +160,30 @@ class SubtaskResponse(BaseModel): vision_output: Optional[str] = None +class AcquisitionConfig(BaseModel): + """#97: camera (servizio + sorgente) e modo di analisi scelti nel task.""" + camera_url: str = Field(..., max_length=500) + camera_source: str = Field(..., min_length=1, max_length=200) + modo: Literal["impulsiva", "in_linea"] = "impulsiva" + + @field_validator("camera_url") + @classmethod + def _check_camera_url(cls, value: str) -> str: + # stessa normalizzazione delle stazioni; qui il vuoto non e' ammesso + normalized = _normalize_http_url(value, "camera_url") + if normalized is None: + raise ValueError("camera_url must not be empty") + return normalized + + @field_validator("camera_source") + @classmethod + def _check_camera_source(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("camera_source must not be empty") + return value + + class TaskCreate(BaseModel): """Schema for creating a task.""" title: str = Field(..., min_length=1, max_length=255) @@ -169,6 +194,7 @@ class TaskCreate(BaseModel): file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") annotations_json: Optional[dict[str, Any]] = None vision_json: Optional[dict] = None + acquisition_json: Optional[AcquisitionConfig] = None # #97 # #53: assente = default dal tipo (misure e check nel ciclo) in_loop: Optional[bool] = None at_close: bool = False @@ -186,6 +212,7 @@ class TaskUpdate(BaseModel): file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") annotations_json: Optional[dict[str, Any]] = None vision_json: Optional[dict] = None + acquisition_json: Optional[AcquisitionConfig] = None # #97 in_loop: Optional[bool] = None at_close: Optional[bool] = None fields: Optional[list[dict[str, Any]]] = None @@ -207,6 +234,7 @@ class TaskResponse(BaseModel): file_type: Optional[str] = None annotations_json: Optional[dict[str, Any]] = None vision_json: Optional[dict] = None + acquisition_json: Optional[AcquisitionConfig] = None # #97 in_loop: bool = False at_close: bool = False fields: Optional[list[dict[str, Any]]] = None diff --git a/src/backend/models/api/vision.py b/src/backend/models/api/vision.py index d1d1782..155f78e 100644 --- a/src/backend/models/api/vision.py +++ b/src/backend/models/api/vision.py @@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel): failures: list[dict] = [] +class VisionCheckResponse(BaseModel): + """What `POST /api/vision/check` returns (#97): the analysis, nothing saved.""" + outcome: str | None = None + overlay: list[dict] = [] + failures: list[dict] = [] + measurements: list[VisionMeasurementResult] = [] + # esito della lettura della calibrazione: ok | no_camera | not_calibrated | unreachable + calibration: str + + class VisionRecipeOutput(BaseModel): """One numeric output a recipe offers to the quotes.""" key: str diff --git a/src/backend/models/orm/task.py b/src/backend/models/orm/task.py index ff23946..0bdfc9a 100644 --- a/src/backend/models/orm/task.py +++ b/src/backend/models/orm/task.py @@ -65,6 +65,9 @@ class RecipeTask(Base): # Cartella immutabile (relativa a upload_path) con recipe.json e i file di # meta della ricetta VisionSuite v2 (#46). vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + # #97: camera e modo di analisi del task, {camera_url, camera_source, modo}; + # NULL = camera della stazione corrente, analisi impulsiva. + acquisition_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # #53: il task fa parte del ciclo di produzione (default dal tipo: misure e # check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano). in_loop: Mapped[bool] = mapped_column( diff --git a/src/backend/services/recipe_service.py b/src/backend/services/recipe_service.py index 4ed7afe..d730bfd 100644 --- a/src/backend/services/recipe_service.py +++ b/src/backend/services/recipe_service.py @@ -117,6 +117,8 @@ async def _copy_tasks_to_version( vision_json=task.vision_json, # Cartella immutabile: basta il riferimento (#46). vision_recipe_dir=task.vision_recipe_dir, + # #97: camera e modo di analisi seguono il task nella nuova versione. + acquisition_json=task.acquisition_json, # #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione. in_loop=task.in_loop, at_close=task.at_close, diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index 87ea2ac..5f7f332 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -231,35 +231,36 @@ CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore 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). +async def fetch_calibration( + camera_url: str | None, camera_source: str | None, label: str = "", +) -> tuple[dict | None, str]: + """Legge la calibrazione da un acquisitore (C1, #83; generalizzata in #97). 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. + Un px_per_mm non finito o <= 0 conta come non calibrata. `label` serve + solo ai log (stazione o task). """ - if station is None or not station.camera_url or not station.camera_source: + if not camera_url or not camera_source: return None, CAL_NO_CAMERA - url = f"{station.camera_url.rstrip('/')}/camera/calibration" + url = f"{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}) + response = await client.get(url, params={"source": camera_source}) except httpx.HTTPError as exc: - logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc) + logger.warning("acquisitore %s non raggiungibile: %s", label, 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, - ) + logger.warning("acquisitore %s: HTTP %s", label, response.status_code) return None, CAL_UNREACHABLE try: body = response.json() except ValueError: - logger.warning("acquisitore della stazione %s: risposta non JSON", station.code) + logger.warning("acquisitore %s: risposta non JSON", label) 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): @@ -267,6 +268,33 @@ async def fetch_station_calibration(station: Station | None) -> tuple[dict | Non return body, CAL_OK +async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]: + """Calibrazione dall'acquisitore della stazione (C1, #83): vedi fetch_calibration.""" + if station is None: + return None, CAL_NO_CAMERA + return await fetch_calibration( + station.camera_url, station.camera_source, f"della stazione {station.code}", + ) + + +def task_camera(task: RecipeTask, station: Station | None) -> tuple[str | None, str | None]: + """Camera di un task (#97): acquisition_json, altrimenti quella della stazione.""" + acquisition = task.acquisition_json + if isinstance(acquisition, dict) and acquisition.get("camera_url") and acquisition.get("camera_source"): + return acquisition["camera_url"], acquisition["camera_source"] + if station is not None: + return station.camera_url, station.camera_source + return None, None + + +async def fetch_task_calibration( + task: RecipeTask, station: Station | None, +) -> tuple[dict | None, str]: + """Calibrazione della camera del task (#97), stessi esiti CAL_* di fetch_calibration.""" + camera_url, camera_source = task_camera(task, station) + return await fetch_calibration(camera_url, camera_source, f"del task {task.id}") + + 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) @@ -498,14 +526,15 @@ 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 + # Calibrazione letta dall'acquisitore (#83); None = non c'e'. #97: la camera e' + # quella del task se la ricetta ne sceglie una, altrimenti quella della stazione. + station = 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) + calibration, _ = await fetch_task_calibration(task, station) if not task.vision_json: raise HTTPException( @@ -623,6 +652,85 @@ async def execute_task( return result, saved, report +async def check_task( + db: AsyncSession, + task: RecipeTask, + image_bytes: bytes, + *, + production_run_id: int | None = None, +) -> dict: + """Esegue il grafo come execute_task ma NON salva nulla (#97). + + Anteprima per l'operatore (ingresso nel task, ciclo «in linea»): niente + VisionResult, niente file, niente misure. L'esito per quota usa + calculate_pass_fail sulle quote con vision_output, nell'ordine di + marker_number come execute. Le quote che il grafo non produce (pezzo non + ancora in posizione) restano fuori da `measurements` e il perche' sta in + `failures`: un'anteprima non deve dare 422 a ogni frame. + """ + if task.task_type not in VISION_TASK_TYPES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"task type {task.task_type!r} is not a camera measurement", + ) + station = None + station_url = None + if production_run_id is not None: + # stesse regole di execute: 404 / 422 / 409 sulla sessione di produzione + run = await measurement_service.validate_run_for_version( + db, production_run_id, task.version_id, + ) + 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 + if not task.vision_json: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="this task carries no vision graph", + ) + quotes = (await db.execute( + select(RecipeSubtask) + .where(RecipeSubtask.task_id == task.id) + .where(RecipeSubtask.vision_output.is_not(None)) + .order_by(RecipeSubtask.marker_number) + )).scalars().all() + if not quotes: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="no quote on this task names a vision output", + ) + + # stessa calibrazione di execute: camera del task, altrimenti della stazione + calibration, cal_reason = await fetch_task_calibration(task, station) + 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, + **worker_kwargs, + ) + outputs = report["outputs"] + measurements = [ + { + "subtask_id": q.id, + "value": float(outputs[q.vision_output]), + "pass_fail": measurement_service.calculate_pass_fail( + float(outputs[q.vision_output]), q, + )[0], + } + for q in quotes if _is_measurable(outputs.get(q.vision_output)) + ] + return { + "outcome": report.get("outcome"), + "overlay": report.get("overlay") or [], + "failures": report.get("failures") or [], + "measurements": measurements, + "calibration": cal_reason, + } + + def _reference_file_path(reference: VisionReferenceImage) -> Path: return settings.upload_path / reference.path diff --git a/src/backend/tests/test_migrations.py b/src/backend/tests/test_migrations.py index 5d4baf4..e124a82 100644 --- a/src/backend/tests/test_migrations.py +++ b/src/backend/tests/test_migrations.py @@ -341,3 +341,22 @@ class TestStationCameraMigration: assert "vision_worker_url" in cols("stations") # la 019 resta command.upgrade(cfg, "head") # e di nuovo su assert {"camera_url", "camera_source"} <= cols("stations") + + +class TestTaskAcquisitionMigration: + """021_task_acquisition: recipe_tasks.acquisition_json, su e giu (#97).""" + + def test_021_upgrade_and_downgrade(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + + def cols(table): + return {c["name"] for c in _inspect(db_path).get_columns(table)} + + command.upgrade(cfg, "head") + assert "acquisition_json" in cols("recipe_tasks") + command.downgrade(cfg, "020_station_camera") + assert "acquisition_json" not in cols("recipe_tasks") + assert "camera_url" in cols("stations") # la 020 resta + command.upgrade(cfg, "head") # e di nuovo su + assert "acquisition_json" in cols("recipe_tasks") diff --git a/src/backend/tests/test_task_acquisition.py b/src/backend/tests/test_task_acquisition.py new file mode 100644 index 0000000..627ecf7 --- /dev/null +++ b/src/backend/tests/test_task_acquisition.py @@ -0,0 +1,308 @@ +"""#97: camera e modo di analisi nel task, POST /api/vision/check senza salvataggio. + +Il worker e l'acquisitore non girano nei test: si fingono (fixture di +test_vision_dxf_compare e test_vision_station_worker). +""" +import pytest +from sqlalchemy import func, select + +from src.backend.config import settings +from src.backend.models.orm.measurement import Measurement +from src.backend.models.orm.task import RecipeSubtask, RecipeTask +from src.backend.models.orm.vision import VisionResult +from src.backend.tests.conftest import auth_headers, create_test_recipe +from src.backend.tests.test_vision_copy_on_write import ( + _current_task_and_subtask, _seed_measurement, +) +from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture acquisitore) + CAL, CAMERA_URL, acquisitore, redirect_uploads, +) +from src.backend.tests.test_vision_execute import an_image +from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls) + _camera_task, _execute, _station_with_run, worker_calls, +) + +TASK_CAMERA = "http://10.0.0.50:8300" +ACQ = {"camera_url": TASK_CAMERA, "camera_source": "camera:601020", "modo": "in_linea"} + + +async def _task_with_acquisition(db_session, user_id, acquisition=ACQ): + recipe, task = await _camera_task(db_session, user_id) + task.acquisition_json = acquisition + await db_session.commit() + return recipe, task + + +# --- modello e API del task --------------------------------------------------- + +@pytest.mark.asyncio +async def test_create_task_stores_and_normalizes_acquisition(client, db_session, maker_user): + recipe = await create_test_recipe(db_session, maker_user.id) + resp = await client.post( + f"/api/recipes/{recipe.id}/tasks", + headers=auth_headers(maker_user), + json={ + "title": "Camera", "task_type": "camera_measure", + "acquisition_json": {"camera_url": " http://h:1/ ", "camera_source": " camera:1 "}, + }, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + # url normalizzato come nelle stazioni, sorgente senza spazi, modo di default + expected = {"camera_url": "http://h:1", "camera_source": "camera:1", "modo": "impulsiva"} + assert body["acquisition_json"] == expected + db_session.expunge_all() + task = (await db_session.execute( + select(RecipeTask).where(RecipeTask.id == body["id"]) + )).scalar_one() + assert task.acquisition_json == expected # sul DB, non solo nella risposta + got = await client.get(f"/api/tasks/{body['id']}", headers=auth_headers(maker_user)) + assert got.json()["acquisition_json"] == expected + + +@pytest.mark.asyncio +async def test_task_without_acquisition_is_null(client, db_session, maker_user): + recipe = await create_test_recipe(db_session, maker_user.id) + resp = await client.get(f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user)) + assert resp.json()[0]["acquisition_json"] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("bad", [ + {"camera_url": "ftp://h", "camera_source": "camera:1"}, + {"camera_url": " ", "camera_source": "camera:1"}, + {"camera_url": "http://h", "camera_source": " "}, + {"camera_url": "http://h", "camera_source": "x" * 201}, + {"camera_url": "http://h", "camera_source": "camera:1", "modo": "continua"}, + {"camera_source": "camera:1"}, +]) +async def test_invalid_acquisition_is_422(client, db_session, maker_user, bad): + recipe = await create_test_recipe(db_session, maker_user.id) + resp = await client.post( + f"/api/recipes/{recipe.id}/tasks", + headers=auth_headers(maker_user), + json={"title": "Camera", "task_type": "camera_measure", "acquisition_json": bad}, + ) + assert resp.status_code == 422, resp.text + + +@pytest.mark.asyncio +async def test_update_sets_and_clears_acquisition(client, db_session, maker_user): + recipe = await create_test_recipe(db_session, maker_user.id) + _, task, _ = await _current_task_and_subtask(db_session, recipe.id) + resp = await client.put( + f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": ACQ}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["acquisition_json"] == ACQ + resp = await client.put( + f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": None}, + ) + assert resp.status_code == 200 + assert resp.json()["acquisition_json"] is None # null = camera della stazione + + +@pytest.mark.asyncio +async def test_copy_on_write_preserves_acquisition(client, db_session, maker_user): + recipe = await create_test_recipe(db_session, maker_user.id) + _, task, _ = await _current_task_and_subtask(db_session, recipe.id) + task.acquisition_json = ACQ + await db_session.flush() + await _seed_measurement(db_session, recipe.id, maker_user.id) + db_session.expunge_all() + + resp = await client.put( + f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user), + json={"change_notes": "forza il copy-on-write"}, + ) + assert resp.status_code == 200 + db_session.expunge_all() + version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id) + assert version.version_number == 2 + assert new_task.acquisition_json == ACQ + + +# --- calibrazione dalla camera del task ---------------------------------------- + +@pytest.mark.asyncio +async def test_execute_reads_calibration_from_the_task_camera( + client, db_session, measurement_tec_user, worker_calls, acquisitore, +): + recipe, task = await _task_with_acquisition(db_session, measurement_tec_user.id) + # stazione con camera propria: la camera del task prevale + 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 acquisitore["urls"] == [(f"{TASK_CAMERA}/camera/calibration", {"source": "camera:601020"})] + assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]} + result = (await db_session.execute(select(VisionResult))).scalars().one() + assert result.calibration_snapshot["px_per_mm"] == CAL["px_per_mm"] + + +@pytest.mark.asyncio +async def test_execute_task_camera_works_without_a_run( + client, db_session, measurement_tec_user, worker_calls, acquisitore, +): + _, task = await _task_with_acquisition(db_session, measurement_tec_user.id) + await _execute(client, measurement_tec_user, task) # niente sessione di produzione + assert acquisitore["urls"][0][0] == f"{TASK_CAMERA}/camera/calibration" + assert "calibration" in worker_calls[0] + + +@pytest.mark.asyncio +async def test_execute_falls_back_to_the_station_camera( + client, db_session, measurement_tec_user, worker_calls, acquisitore, +): + recipe, task = await _camera_task(db_session, measurement_tec_user.id) # acquisition_json null + 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 acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})] + assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]} + + +# --- POST /api/vision/check ---------------------------------------------------- + +async def _check(client, user, task, run=None): + data = {"task_id": str(task.id)} + if run is not None: + data["production_run_id"] = str(run.id) + return await client.post( + "/api/vision/check", headers=auth_headers(user), + files={"image": ("p.png", an_image(), "image/png")}, data=data, + ) + + +async def _counts(db_session): + return tuple([ + (await db_session.execute(select(func.count()).select_from(model))).scalar_one() + for model in (Measurement, VisionResult) + ]) + + +@pytest.mark.asyncio +async def test_check_saves_nothing_and_reports_pass_fail( + client, db_session, measurement_tec_user, monkeypatch, acquisitore, +): + _, task = await _task_with_acquisition(db_session, measurement_tec_user.id) + seen = {} + + async def fake_call(image_bytes, graph, **kwargs): + seen.update(kwargs) + return {"outputs": {"t1.diameter": 11.0}, "failures": [{"tool_name": "t"}], + "overlay": [{"kind": "line"}], "outcome": "fail", + "engine_version": "e" * 40, "duration_ms": 3} + + monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call) + before = await _counts(db_session) + + resp = await _check(client, measurement_tec_user, task) + + assert resp.status_code == 200, resp.text + body = resp.json() + quote = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.task_id == task.id) + )).scalars().one() + assert body == { + "outcome": "fail", "overlay": [{"kind": "line"}], "failures": [{"tool_name": "t"}], + "measurements": [{"subtask_id": quote.id, "value": 11.0, "pass_fail": "fail"}], + "calibration": "ok", + } + assert seen["calibration"] == {"px_per_mm": CAL["px_per_mm"]} # stessa calibrazione di execute + assert await _counts(db_session) == before # niente misure ne' VisionResult + assert not list(settings.upload_path.rglob("*.overlay.json")) # ne' file + + +@pytest.mark.asyncio +@pytest.mark.parametrize("value,expected", [(10.0, "pass"), (10.4, "warning"), (9.0, "fail")]) +async def test_check_pass_fail_levels( + client, db_session, measurement_tec_user, monkeypatch, value, expected, +): + _, task = await _camera_task(db_session, measurement_tec_user.id) + + async def fake_call(image_bytes, graph, **kwargs): + return {"outputs": {"t1.diameter": value}, "failures": [], "engine_version": "e" * 40} + + monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call) + resp = await _check(client, measurement_tec_user, task) + assert resp.json()["measurements"][0]["pass_fail"] == expected + assert resp.json()["calibration"] == "no_camera" # senza camera ne' stazione + + +@pytest.mark.asyncio +async def test_check_calibration_reasons( + client, db_session, measurement_tec_user, worker_calls, acquisitore, +): + _, task = await _task_with_acquisition(db_session, measurement_tec_user.id) + acquisitore["cal"] = None + assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "not_calibrated" + acquisitore["cal"] = "down" + assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "unreachable" + assert "calibration" not in worker_calls[-1] # senza calibrazione la ricetta usa la sua + + +@pytest.mark.asyncio +async def test_check_omits_quotes_the_graph_did_not_produce( + client, db_session, measurement_tec_user, monkeypatch, +): + _, task = await _camera_task(db_session, measurement_tec_user.id) + + async def fake_call(image_bytes, graph, **kwargs): + return {"outputs": {}, "failures": [{"tool_name": "blob"}], "engine_version": "e" * 40} + + monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call) + resp = await _check(client, measurement_tec_user, task) + assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame + assert resp.json()["measurements"] == [] + assert resp.json()["failures"] == [{"tool_name": "blob"}] + + +@pytest.mark.asyncio +async def test_check_422_without_graph_or_quotes_or_vision_type( + client, db_session, measurement_tec_user, worker_calls, +): + _, task = await _camera_task(db_session, measurement_tec_user.id) + task.vision_json = None + await db_session.commit() + assert (await _check(client, measurement_tec_user, task)).status_code == 422 + + task.vision_json = {"schema_version": 1, "name": "p", "tools": []} + quote = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.task_id == task.id) + )).scalars().one() + quote.vision_output = None + await db_session.commit() + assert (await _check(client, measurement_tec_user, task)).status_code == 422 + + task.task_type = "measure" + await db_session.commit() + assert (await _check(client, measurement_tec_user, task)).status_code == 422 + + +@pytest.mark.asyncio +async def test_check_with_run_uses_station_worker_and_station_camera( + 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, "http://10.0.0.7:8100") + station.camera_url, station.camera_source = CAMERA_URL, "camera:A1" + await db_session.commit() + before = await _counts(db_session) + resp = await _check(client, measurement_tec_user, task, run) + assert resp.status_code == 200, resp.text + assert worker_calls[0]["worker_url"] == "http://10.0.0.7:8100" + assert acquisitore["urls"][0][0] == f"{CAMERA_URL}/camera/calibration" + assert await _counts(db_session) == before + + +@pytest.mark.asyncio +async def test_check_requires_login(client, db_session, measurement_tec_user, worker_calls): + _, task = await _camera_task(db_session, measurement_tec_user.id) + resp = await client.post( + "/api/vision/check", files={"image": ("p.png", an_image(), "image/png")}, + data={"task_id": str(task.id)}, + ) + assert resp.status_code in (401, 403)