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