Files
TieMeasureFlow/src/backend/tests/test_measurements.py
T
Adriano 3f03ec8ab1 feat(measure): memorizza tempo di inserimento misura (input_duration_ms)
Colonna nullable su measurements + migration 003, campo opzionale negli
schemi API (create singola e batch), passthrough nel proxy Flask e timer
lato client in task_execute: parte all'attivazione del subtask, si
resetta su auto-advance e navigazione manuale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 14:42:24 +02:00

369 lines
12 KiB
Python

"""Tests for measurements router (/api/measurements)."""
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.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _get_subtask_and_version(
client: AsyncClient, user: User, recipe_id: int
) -> tuple[int, int]:
"""Helper: return (subtask_id, version_id) from the recipe's current version."""
tasks_resp = await client.get(
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user)
)
task = tasks_resp.json()[0]
subtask_id = task["subtasks"][0]["id"]
version_id = task["version_id"]
return subtask_id, version_id
class TestCreateMeasurement:
"""POST /api/measurements/ tests."""
async def test_create_measurement(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""MeasurementTec can create a measurement."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.1,
"input_method": "manual",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["value"] == pytest.approx(10.1, rel=1e-4)
assert data["pass_fail"] in ("pass", "warning", "fail")
assert data["measured_by"] == measurement_tec_user.id
async def test_create_measurement_with_input_duration(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""input_duration_ms is stored and returned when provided."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
"input_duration_ms": 4250,
},
)
assert resp.status_code == 200
assert resp.json()["input_duration_ms"] == 4250
async def test_create_measurement_without_input_duration(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""input_duration_ms defaults to null when omitted."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
},
)
assert resp.status_code == 200
assert resp.json()["input_duration_ms"] is None
async def test_measurement_requires_auth(self, client: AsyncClient):
"""Creating measurement without auth returns 401."""
resp = await client.post(
"/api/measurements/",
json={
"subtask_id": 1,
"version_id": 1,
"value": 10.0,
},
)
assert resp.status_code == 401
async def test_measurement_validation(
self,
client: AsyncClient,
measurement_tec_user: User,
):
"""Measurement with non-existent subtask returns 400."""
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": 99999,
"version_id": 99999,
"value": 10.0,
},
)
assert resp.status_code == 400
class TestMeasurementPassFail:
"""Pass/fail calculation tests."""
async def test_measurement_pass(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Value within tolerance -> pass."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
# nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
},
)
assert resp.status_code == 200
assert resp.json()["pass_fail"] == "pass"
async def test_measurement_warning(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Value outside warning but inside tolerance -> warning."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
# uwl=10.3 < 10.4 < utl=10.5 -> warning
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.4,
},
)
assert resp.status_code == 200
assert resp.json()["pass_fail"] == "warning"
async def test_measurement_fail(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Value outside tolerance limits -> fail."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
# value > utl=10.5 -> fail
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.6,
},
)
assert resp.status_code == 200
assert resp.json()["pass_fail"] == "fail"
class TestListMeasurements:
"""GET /api/measurements/ tests."""
async def test_list_measurements(
self,
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
"""Metrologist can list measurements with pagination."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
# Create some measurements
for val in [9.8, 10.0, 10.2]:
await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": val,
},
)
# List as Metrologist
resp = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
)
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
assert data["total"] >= 3
async def test_measurement_tec_can_list_measurements(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Regression: MeasurementTec must be able to list measurements.
The Flask client renders task_complete.html by calling GET
/api/measurements?version_id=X right after the operator finishes a
recipe. The endpoint used to require Metrologist, which silently
produced an empty riepilogo for every operator without that role.
"""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
for val in [9.8, 10.0, 10.2]:
await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": val,
},
)
resp = await client.get(
f"/api/measurements/?version_id={version_id}",
headers=auth_headers(measurement_tec_user),
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["total"] >= 3
async def test_measurement_by_recipe_filter(
self,
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
"""Filtering measurements by recipe_id returns only matching records."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.0,
},
)
resp = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"recipe_id": recipe.id},
)
assert resp.status_code == 200
assert resp.json()["total"] >= 1
class TestMeasurementStatistics:
"""Measurement deviation / statistics edge case tests."""
async def test_measurement_has_deviation(
self,
client: AsyncClient,
measurement_tec_user: User,
db_session: AsyncSession,
):
"""Measurement response includes deviation from nominal."""
recipe = await create_test_recipe(
db_session, measurement_tec_user.id
)
subtask_id, version_id = await _get_subtask_and_version(
client, measurement_tec_user, recipe.id
)
resp = await client.post(
"/api/measurements/",
headers=auth_headers(measurement_tec_user),
json={
"subtask_id": subtask_id,
"version_id": version_id,
"value": 10.2,
},
)
assert resp.status_code == 200
data = resp.json()
# deviation = 10.2 - nominal(10.0) = 0.2
assert data["deviation"] is not None
assert abs(data["deviation"] - 0.2) < 0.001