38ef0587f5
I2: _call_worker lasciava propagare httpx.ConnectError e ReadTimeout (worker fermo o job oltre i 120s), senza handler in main.py: l'operatore vedeva un 500 nudo. Ora ConnectError->502, ReadTimeout->504, ciascuno con un messaggio che nomina il guasto. La forma della risposta è validata alla frontiera: outputs/failures/engine_version mancanti o un body non- dict non fanno più KeyError, rispondono 502 "malformed". M3: il gate del fuori tolleranza (pending_authorisation) girava dopo _call_worker: un operatore bloccato bruciava un'intera esecuzione di visione prima di ricevere il 409. Spostato prima della chiamata al worker; le quote necessarie erano già caricate prima, nessun'altra query serviva. Copertura: test_vision_worker_transport.py (nuovo) per i quattro casi di guasto/malformazione; test_vision_execute.py aggiunge test_a_pending_fail_blocks_before_the_worker_is_called, rossa prima dello spostamento del gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""A quote from a camera is a measurement like any other: same verdict, same gate."""
|
|
import io
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
from sqlalchemy import select
|
|
|
|
from src.backend.models.orm.measurement import Measurement
|
|
from src.backend.models.orm.recipe import RecipeVersion
|
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
|
from src.backend.models.orm.vision import VisionResult
|
|
from src.backend.services import measurement_service
|
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
|
|
|
GOOD = 10.0 # inside every limit of the fixture quote
|
|
OUT = 99.0 # far outside
|
|
|
|
|
|
def an_image() -> bytes:
|
|
buffer = io.BytesIO()
|
|
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
|
return buffer.getvalue()
|
|
|
|
|
|
async def recipe_parts(db_session, recipe_id: int):
|
|
"""`create_test_recipe` returns only the recipe: here it is broken apart."""
|
|
version = (await db_session.execute(
|
|
select(RecipeVersion).where(
|
|
RecipeVersion.recipe_id == recipe_id,
|
|
RecipeVersion.is_current == True, # noqa: E712
|
|
)
|
|
)).scalar_one()
|
|
task = (await db_session.execute(
|
|
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
|
)).scalars().first()
|
|
subtask = (await db_session.execute(
|
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
|
)).scalars().first()
|
|
return version, task, subtask
|
|
|
|
|
|
async def two_vision_quotes(db_session, recipe_id: int):
|
|
"""The fixture recipe's task, with a second quote mapped to a vision output.
|
|
|
|
Same shape as `test_out_of_tolerance.py::_two_quotes`, adapted so both
|
|
quotes belong to a single camera acquisition instead of two separate
|
|
manual readings.
|
|
"""
|
|
version, task, first = await recipe_parts(db_session, recipe_id)
|
|
first.vision_output = "t1.diameter"
|
|
|
|
second = RecipeSubtask(
|
|
task_id=task.id, marker_number=2, description="Second quote",
|
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
|
vision_output="t1.radius",
|
|
)
|
|
db_session.add(second)
|
|
task.task_type = "camera_measure"
|
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
|
await db_session.commit()
|
|
await db_session.refresh(second)
|
|
return version, task, first, second
|
|
|
|
|
|
@pytest.fixture
|
|
def worker_says(monkeypatch):
|
|
"""The worker does not run in tests: its response is faked here."""
|
|
def _install(outputs: dict, failures: list | None = None):
|
|
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
|
return {
|
|
"outputs": outputs,
|
|
"failures": failures or [],
|
|
"engine_version": "a" * 40,
|
|
"duration_ms": 12,
|
|
}
|
|
monkeypatch.setattr(
|
|
"src.backend.services.vision_service._call_worker", fake_call,
|
|
)
|
|
return _install
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_camera_quote_becomes_a_measurement(
|
|
client, db_session, measurement_tec_user, worker_says,
|
|
):
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
task.task_type = "camera_measure"
|
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
|
subtask.vision_output = "t1.diameter"
|
|
# The fixture creates the quote with nominal 10 and tight limits around it.
|
|
await db_session.commit()
|
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
|
|
|
response = await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
saved = (await db_session.execute(
|
|
select(Measurement).where(Measurement.subtask_id == subtask.id)
|
|
)).scalars().all()
|
|
assert len(saved) == 1
|
|
assert saved[0].pass_fail == "pass"
|
|
assert saved[0].input_method == "camera"
|
|
assert saved[0].vision_result_id is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_the_execution_records_which_engine_produced_it(
|
|
client, db_session, measurement_tec_user, worker_says,
|
|
):
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
task.task_type = "camera_measure"
|
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
|
subtask.vision_output = "t1.diameter"
|
|
await db_session.commit()
|
|
worker_says({"t1.diameter": float(subtask.nominal)})
|
|
|
|
await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
|
)
|
|
|
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
|
assert result.engine_version == "a" * 40
|
|
assert result.executed_on == "server"
|
|
assert result.graph_snapshot == task.vision_json
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_missing_output_names_itself(
|
|
client, db_session, measurement_tec_user, worker_says,
|
|
):
|
|
"""No half measurement: if an output is missing, the error names it."""
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
task.task_type = "camera_measure"
|
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
|
subtask.vision_output = "t1.diameter"
|
|
await db_session.commit()
|
|
worker_says({"t1.radius": 5.0})
|
|
|
|
response = await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id), "lot_number": "L1"},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert "t1.diameter" in response.json()["detail"]
|
|
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_task_without_a_graph_is_refused(
|
|
client, db_session, measurement_tec_user, worker_says,
|
|
):
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
|
task.task_type = "camera_measure"
|
|
await db_session.commit()
|
|
worker_says({})
|
|
|
|
response = await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id)},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert "graph" in response.json()["detail"].lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ruling R10: the gate is evaluated once per acquisition, not once per quote.
|
|
# A camera acquisition produces every quote of the task at the same instant -
|
|
# there is no "moving on" between them, so the sequential gate must not fire
|
|
# inside the batch. It still has to fire between two separate acquisitions.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_non_last_out_of_tolerance_quote_does_not_break_the_batch(
|
|
client, db_session, measurement_tec_user, worker_says,
|
|
):
|
|
"""The quote evaluated first fails; the one after it must still be saved."""
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
|
worker_says({"t1.diameter": OUT, "t1.radius": GOOD})
|
|
|
|
response = await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id)},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
by_subtask = {
|
|
m.subtask_id: m.pass_fail
|
|
for m in (await db_session.execute(select(Measurement))).scalars().all()
|
|
}
|
|
assert by_subtask == {first.id: "fail", second.id: "pass"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_pending_fail_outside_the_batch_still_blocks(
|
|
client, db_session, measurement_tec_user, worker_says,
|
|
):
|
|
"""A fail left over from something else this camera task does not cover."""
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
|
|
|
# A third quote on the same task, not part of this acquisition (no vision
|
|
# output), already failed and is waiting on a supervisor.
|
|
third = RecipeSubtask(
|
|
task_id=task.id, marker_number=3, description="Not a vision quote",
|
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
|
)
|
|
db_session.add(third)
|
|
await db_session.commit()
|
|
await db_session.refresh(third)
|
|
await measurement_service.save_measurement(
|
|
db_session, subtask_id=third.id, version_id=version.id,
|
|
measured_by=measurement_tec_user.id, value=OUT,
|
|
)
|
|
await db_session.commit()
|
|
|
|
worker_says({"t1.diameter": GOOD, "t1.radius": GOOD})
|
|
response = await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id)},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert "supervisor" in response.json()["detail"].lower()
|
|
assert (await db_session.execute(
|
|
select(Measurement).where(Measurement.subtask_id.in_([first.id, second.id]))
|
|
)).scalars().all() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_pending_fail_blocks_before_the_worker_is_called(
|
|
client, db_session, measurement_tec_user, monkeypatch,
|
|
):
|
|
"""M3: a blocked operator must not burn a whole vision execution to learn
|
|
they are blocked. The gate is checked before the worker runs, not after."""
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
|
|
|
third = RecipeSubtask(
|
|
task_id=task.id, marker_number=3, description="Not a vision quote",
|
|
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
|
)
|
|
db_session.add(third)
|
|
await db_session.commit()
|
|
await db_session.refresh(third)
|
|
await measurement_service.save_measurement(
|
|
db_session, subtask_id=third.id, version_id=version.id,
|
|
measured_by=measurement_tec_user.id, value=OUT,
|
|
)
|
|
await db_session.commit()
|
|
|
|
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict:
|
|
raise AssertionError("the worker must not run while the operator is blocked")
|
|
monkeypatch.setattr(
|
|
"src.backend.services.vision_service._call_worker", worker_must_not_be_called,
|
|
)
|
|
|
|
response = await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id)},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert "supervisor" in response.json()["detail"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_new_acquisition_can_remeasure_its_own_pending_quote(
|
|
client, db_session, measurement_tec_user, worker_says,
|
|
):
|
|
"""Re-shooting the same task is the way out, same as re-measuring by hand."""
|
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
|
|
|
worker_says({"t1.diameter": OUT, "t1.radius": GOOD})
|
|
first_shot = await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p1.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id)},
|
|
)
|
|
assert first_shot.status_code == 200
|
|
|
|
# first.id is now pending authorisation - but it belongs to this same
|
|
# task, so a new acquisition of it is not "moving on".
|
|
worker_says({"t1.diameter": GOOD, "t1.radius": GOOD})
|
|
second_shot = await client.post(
|
|
"/api/vision/execute",
|
|
headers=auth_headers(measurement_tec_user),
|
|
files={"image": ("p2.png", an_image(), "image/png")},
|
|
data={"task_id": str(task.id)},
|
|
)
|
|
assert second_shot.status_code == 200
|
|
|
|
readings = (await db_session.execute(
|
|
select(Measurement)
|
|
.where(Measurement.subtask_id == first.id)
|
|
.order_by(Measurement.id)
|
|
)).scalars().all()
|
|
assert [m.pass_fail for m in readings] == ["fail", "pass"]
|
|
|
|
assert await measurement_service.pending_authorisation(
|
|
db_session, version.id, measurement_tec_user.id,
|
|
) is None
|