"""Tests for statistics router (/api/statistics).""" import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from src.backend.models.orm.user import User from src.backend.models.orm.measurement import Measurement from src.backend.tests.conftest import auth_headers, create_test_recipe async def _seed_measurements( client: AsyncClient, tec_user: User, recipe_id: int, values: list[float], ) -> tuple[int, int]: """Create measurements and return (subtask_id, version_id).""" tasks_resp = await client.get( f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(tec_user) ) task = tasks_resp.json()[0] subtask_id = task["subtasks"][0]["id"] version_id = task["version_id"] for val in values: await client.post( "/api/measurements/", headers=auth_headers(tec_user), json={ "subtask_id": subtask_id, "version_id": version_id, "value": val, }, ) return subtask_id, version_id class TestSummary: """GET /api/statistics/summary tests.""" async def test_get_statistics_by_recipe( self, client: AsyncClient, measurement_tec_user: User, metrologist_user: User, db_session: AsyncSession, ): """Metrologist can get summary statistics.""" recipe = await create_test_recipe( db_session, measurement_tec_user.id ) await _seed_measurements( client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.4, 10.6, 9.4], # pass, pass, warning, fail, fail ) resp = await client.get( "/api/statistics/summary", headers=auth_headers(metrologist_user), params={"recipe_id": recipe.id}, ) assert resp.status_code == 200 data = resp.json() assert data["total"] == 5 assert data["pass_count"] >= 0 assert data["warning_count"] >= 0 assert data["fail_count"] >= 0 assert data["pass_rate"] >= 0 # Total rates should sum to ~100 total_rate = data["pass_rate"] + data["warning_rate"] + data["fail_rate"] assert abs(total_rate - 100.0) < 0.1 async def test_statistics_require_metrologist( self, client: AsyncClient, maker_user: User, db_session: AsyncSession, ): """Non-Metrologist cannot access statistics.""" recipe = await create_test_recipe(db_session, maker_user.id) resp = await client.get( "/api/statistics/summary", headers=auth_headers(maker_user), params={"recipe_id": recipe.id}, ) assert resp.status_code == 403 async def test_statistics_empty( self, client: AsyncClient, metrologist_user: User, db_session: AsyncSession, ): """Statistics on recipe with no measurements returns zeros.""" recipe = await create_test_recipe( db_session, metrologist_user.id ) resp = await client.get( "/api/statistics/summary", headers=auth_headers(metrologist_user), params={"recipe_id": recipe.id}, ) assert resp.status_code == 200 data = resp.json() assert data["total"] == 0 assert data["pass_count"] == 0 class TestCapability: """GET /api/statistics/capability tests.""" async def test_capability_indices( self, client: AsyncClient, measurement_tec_user: User, metrologist_user: User, db_session: AsyncSession, ): """Capability endpoint returns Cp/Cpk/Pp/Ppk indices.""" recipe = await create_test_recipe( db_session, measurement_tec_user.id ) subtask_id, _ = await _seed_measurements( client, measurement_tec_user, recipe.id, [9.9, 10.0, 10.1, 9.8, 10.2, 10.0, 9.95, 10.05, 10.1, 9.9], ) resp = await client.get( "/api/statistics/capability", headers=auth_headers(metrologist_user), params={ "recipe_id": recipe.id, "subtask_id": subtask_id, }, ) assert resp.status_code == 200 data = resp.json() assert data["n"] == 10 assert data["mean"] is not None assert data["std_dev"] is not None # With UTL=10.5 and LTL=9.5, spread=1.0, Cp should be > 0 if data["cp"] is not None: assert data["cp"] > 0 class TestSPCData: """GET /api/statistics/control-chart tests.""" async def test_spc_data( self, client: AsyncClient, measurement_tec_user: User, metrologist_user: User, db_session: AsyncSession, ): """Control chart returns values, mean, UCL, LCL.""" recipe = await create_test_recipe( db_session, measurement_tec_user.id ) subtask_id, _ = await _seed_measurements( client, measurement_tec_user, recipe.id, [10.0, 10.1, 9.9, 10.05, 9.95], ) resp = await client.get( "/api/statistics/control-chart", headers=auth_headers(metrologist_user), params={ "recipe_id": recipe.id, "subtask_id": subtask_id, }, ) assert resp.status_code == 200 data = resp.json() assert len(data["values"]) == 5 assert data["mean"] is not None assert data["ucl"] is not None assert data["lcl"] is not None assert data["ucl"] > data["mean"] assert data["lcl"] < data["mean"] class TestDateFilter: """Date filter tests.""" async def test_statistics_date_filter( self, client: AsyncClient, measurement_tec_user: User, metrologist_user: User, db_session: AsyncSession, ): """Date filter parameters are accepted without error.""" recipe = await create_test_recipe( db_session, measurement_tec_user.id ) await _seed_measurements( client, measurement_tec_user, recipe.id, [10.0, 10.1], ) resp = await client.get( "/api/statistics/summary", headers=auth_headers(metrologist_user), params={ "recipe_id": recipe.id, "date_from": "2020-01-01T00:00:00", "date_to": "2099-12-31T23:59:59", }, ) assert resp.status_code == 200 # All measurements should be within this wide range assert resp.json()["total"] >= 2 class TestRunFilter: """#58: statistiche per sessione di produzione.""" async def _setup(self, db_session, user, metro): from datetime import datetime from src.backend.models.orm.production import ProductionRun from src.backend.models.orm.station import Station from src.backend.models.orm.recipe import RecipeVersion from sqlalchemy import select recipe = await create_test_recipe(db_session, user.id) version = (await db_session.execute( select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id) )).scalars().first() station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id) db_session.add(station) await db_session.commit() runs = [] for i, lot in enumerate(["L1", "L2"]): run = ProductionRun( station_id=station.id, recipe_id=recipe.id, version_id=version.id, operator_id=user.id, status="closed", lot_number=lot, started_at=datetime(2026, 1, 1 + i), ) db_session.add(run) runs.append(run) await db_session.commit() return recipe, runs async def test_filter_by_run_and_unfiltered( self, client, measurement_tec_user, metrologist_user, db_session, ): from src.backend.models.orm.measurement import Measurement recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user) subtask_id, version_id = await _seed_measurements( client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2], ) # Assegna le prime due misure al run 1, la terza al run 2. from sqlalchemy import select ms = (await db_session.execute( select(Measurement).order_by(Measurement.id) )).scalars().all() ms[0].production_run_id = runs[0].id ms[1].production_run_id = runs[0].id ms[2].production_run_id = runs[1].id await db_session.commit() h = auth_headers(metrologist_user) r1 = await client.get("/api/statistics/summary", headers=h, params={"recipe_id": recipe.id, "production_run_id": runs[0].id}) assert r1.json()["total"] == 2 r2 = await client.get("/api/statistics/summary", headers=h, params={"recipe_id": recipe.id, "production_run_id": runs[1].id}) assert r2.json()["total"] == 1 r_all = await client.get("/api/statistics/summary", headers=h, params={"recipe_id": recipe.id}) assert r_all.json()["total"] == 3 runs_resp = await client.get("/api/statistics/runs", headers=h, params={"recipe_id": recipe.id}) assert runs_resp.status_code == 200 data = runs_resp.json() assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima assert [r["measurement_count"] for r in data] == [1, 2] assert data[0]["lot_number"] == "L2" assert data[0]["station_name"] == "Stazione 58" assert data[0]["closed_at"] is None async def test_runs_require_metrologist( self, client, measurement_tec_user, db_session, ): resp = await client.get("/api/statistics/runs", headers=auth_headers(measurement_tec_user), params={"recipe_id": 1}) assert resp.status_code == 403 class TestRunTaskResults: """#58: GET /api/statistics/runs/{id}/task-results.""" FIELDS = [ {"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False}, {"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True}, ] async def _setup(self, client, db_session, maker_user, tec_user): from datetime import datetime from sqlalchemy import select from src.backend.models.orm.production import ProductionRun from src.backend.models.orm.recipe import RecipeVersion from src.backend.models.orm.station import Station from src.backend.models.orm.task_result import TaskResult recipe = await create_test_recipe(db_session, maker_user.id, code="TR58") version = (await db_session.execute( select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id) )).scalars().first() db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id)) await db_session.commit() station = (await db_session.execute(select(Station))).scalars().first() run = ProductionRun( station_id=station.id, recipe_id=recipe.id, version_id=version.id, operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1), ) db_session.add(run) await db_session.commit() h = auth_headers(maker_user) check = (await client.post( f"/api/recipes/{recipe.id}/tasks", headers=h, json={"title": "Controllo", "task_type": "check"}, )).json() note = (await client.post( f"/api/recipes/{recipe.id}/tasks", headers=h, json={"title": "Nota", "task_type": "note", "fields": self.FIELDS}, )).json() # Inseriti fuori ordine: l'API deve ordinare per created_at. rows = [ TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id, user_id=tec_user.id, outcome="done", note="ok finito", values={"peso": "12", "foto": "x.jpg"}, attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}], created_at=datetime(2026, 1, 1, 10, 3)), TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id, user_id=tec_user.id, outcome="nok", note="graffio", created_at=datetime(2026, 1, 1, 10, 2)), TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id, user_id=tec_user.id, outcome="ok", created_at=datetime(2026, 1, 1, 10, 1)), # Un esito di un'altra sessione (nessuna) non deve comparire. TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None, user_id=tec_user.id, outcome="ok", created_at=datetime(2026, 1, 1, 9, 0)), ] db_session.add_all(rows) await db_session.commit() return recipe, run, check, note async def test_counts_order_labels_attachments( self, client, maker_user, measurement_tec_user, metrologist_user, db_session, ): _, run, check, note = await self._setup( client, db_session, maker_user, measurement_tec_user) resp = await client.get(f"/api/statistics/runs/{run.id}/task-results", headers=auth_headers(metrologist_user)) assert resp.status_code == 200, resp.text data = resp.json() assert data["run_id"] == run.id assert data["counts"] == {"ok": 1, "nok": 1, "done": 1} res = data["results"] assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente assert res[1]["note"] == "graffio" assert res[1]["task_title"] == "Controllo" assert res[1]["task_type"] == "check" assert res[1]["user_name"] == measurement_tec_user.display_name assert res[1]["values"] == [] and res[1]["attachments"] == [] n = res[2] assert n["task_title"] == "Nota" and n["note"] == "ok finito" assert n["values"] == [ {"key": "peso", "label": "Peso", "value": "12"}, {"key": "foto", "label": "Foto", "value": "x.jpg"}, ] assert n["attachments"] == [ {"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"}, ] async def test_label_falls_back_to_key_when_field_removed( self, client, maker_user, measurement_tec_user, metrologist_user, db_session, ): from src.backend.models.orm.task import RecipeTask _, run, _, note = await self._setup( client, db_session, maker_user, measurement_tec_user) task = await db_session.get(RecipeTask, note["id"]) task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task await db_session.commit() resp = await client.get(f"/api/statistics/runs/{run.id}/task-results", headers=auth_headers(metrologist_user)) n = resp.json()["results"][2] assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"} assert n["attachments"][0]["label"] == "foto" async def test_unknown_run_is_404(self, client, metrologist_user): resp = await client.get("/api/statistics/runs/99999/task-results", headers=auth_headers(metrologist_user)) assert resp.status_code == 404 async def test_measurement_tec_is_403(self, client, measurement_tec_user): resp = await client.get("/api/statistics/runs/1/task-results", headers=auth_headers(measurement_tec_user)) assert resp.status_code == 403