c911449b7c
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
403 lines
16 KiB
Python
403 lines
16 KiB
Python
"""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
|