3f03ec8ab1
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>
369 lines
12 KiB
Python
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
|