Test ricetta v2, execute dxf_compare, migrazione 016 (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -131,3 +131,21 @@ class TestApiKeyHashMigration:
|
|||||||
|
|
||||||
command.upgrade(cfg, "head") # and up again
|
command.upgrade(cfg, "head") # and up again
|
||||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
class TestVisionRecipeDirMigration:
|
||||||
|
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
||||||
|
|
||||||
|
def test_016_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols():
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert "vision_recipe_dir" in cols()
|
||||||
|
command.downgrade(cfg, "015_api_key_hash")
|
||||||
|
assert "vision_recipe_dir" not in cols()
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert "vision_recipe_dir" in cols()
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""Ricetta VisionSuite v2 su un task, execute dxf_compare, copy-on-write (#46)."""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
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.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
RECIPE = {
|
||||||
|
"schema_version": 2, "name": "dxf", "tools": [],
|
||||||
|
"meta": {"dxf": {"d1": {"path": "dxf/part.dxf"}}, "models": {"m": {"path": "models/m.npz"}}},
|
||||||
|
}
|
||||||
|
OUTPUTS = [{"key": "cmp.points_in_pct", "quantity": "ratio", "unit": "%"}]
|
||||||
|
GOOD_FILES = (("part.dxf", b"0 EOF"), ("m.npz", b"npz"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def redirect_uploads(monkeypatch, tmp_path):
|
||||||
|
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def validate_says(monkeypatch):
|
||||||
|
"""Il worker /validate non gira nei test: risposta finta."""
|
||||||
|
def _install(ok=True, errors=None, outputs=None):
|
||||||
|
async def fake(graph, recipe_dir):
|
||||||
|
return {
|
||||||
|
"ok": ok, "errors": errors or [],
|
||||||
|
"outputs": outputs if outputs is not None else OUTPUTS,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
|
||||||
|
return _install
|
||||||
|
|
||||||
|
|
||||||
|
def an_image() -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("L", (32, 32)).save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def make_task(db_session, user, task_type="dxf_compare"):
|
||||||
|
recipe = await create_test_recipe(db_session, user.id)
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||||
|
)).scalars().first()
|
||||||
|
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()
|
||||||
|
task.task_type = task_type
|
||||||
|
await db_session.commit()
|
||||||
|
return task, subtask
|
||||||
|
|
||||||
|
|
||||||
|
def upload(client, user, task, recipe=RECIPE, files=()):
|
||||||
|
return client.post(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files=[("recipe", ("r.json", json.dumps(recipe).encode(), "application/json"))]
|
||||||
|
+ [("files", (n, c, "application/octet-stream")) for n, c in files],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_ok_stores_folder_and_task(client, db_session, maker_user, validate_says):
|
||||||
|
validate_says()
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await upload(client, maker_user, task, files=GOOD_FILES)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["outputs"] == OUTPUTS
|
||||||
|
assert sorted(body["files"]) == ["dxf/part.dxf", "models/m.npz"]
|
||||||
|
assert body["recipe_dir"].startswith(f"vision/recipes/{task.id}/")
|
||||||
|
folder = settings.upload_path / body["recipe_dir"]
|
||||||
|
assert (folder / "recipe.json").is_file() and (folder / "dxf" / "part.dxf").is_file()
|
||||||
|
await db_session.refresh(task)
|
||||||
|
assert task.vision_json == RECIPE and task.vision_recipe_dir == body["recipe_dir"]
|
||||||
|
|
||||||
|
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
|
||||||
|
assert got.status_code == 200
|
||||||
|
assert got.json()["recipe"] == RECIPE and got.json()["outputs"] == OUTPUTS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_without_recipe_is_empty(client, db_session, maker_user, validate_says):
|
||||||
|
validate_says()
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
|
||||||
|
assert got.json() == {
|
||||||
|
"task_id": task.id, "recipe_dir": None, "recipe": None, "files": [], "outputs": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unmatched_file_name_is_422_list(client, db_session, maker_user, validate_says):
|
||||||
|
validate_says()
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await upload(client, maker_user, task, files=[("other.dxf", b"x")])
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert isinstance(resp.json()["detail"], list) and "other.dxf" in resp.json()["detail"][0]
|
||||||
|
await db_session.refresh(task)
|
||||||
|
assert task.vision_json is None and task.vision_recipe_dir is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_traversal_in_meta_path_is_422(client, db_session, maker_user, validate_says):
|
||||||
|
validate_says()
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
evil = {"schema_version": 2, "tools": [], "meta": {"dxf": {"d": {"path": "../../evil.dxf"}}}}
|
||||||
|
resp = await upload(client, maker_user, task, recipe=evil, files=[("evil.dxf", b"x")])
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert not (settings.upload_path.parent / "evil.dxf").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wrong_task_type_is_422(client, db_session, maker_user, validate_says):
|
||||||
|
validate_says()
|
||||||
|
task, _ = await make_task(db_session, maker_user, task_type="measure")
|
||||||
|
resp = await upload(client, maker_user, task, files=GOOD_FILES)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_json_is_422(client, db_session, maker_user, validate_says):
|
||||||
|
validate_says()
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user),
|
||||||
|
files=[("recipe", ("r.json", b"{nope", "application/json"))],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_worker_not_ok_is_422_and_task_unchanged(client, db_session, maker_user, validate_says):
|
||||||
|
validate_says(ok=False, errors=["meta file missing"])
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
task.vision_json = {"old": 1}
|
||||||
|
task.vision_recipe_dir = "vision/recipes/old"
|
||||||
|
await db_session.commit()
|
||||||
|
resp = await upload(client, maker_user, task, files=GOOD_FILES)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == ["meta file missing"]
|
||||||
|
await db_session.refresh(task)
|
||||||
|
assert task.vision_json == {"old": 1} and task.vision_recipe_dir == "vision/recipes/old"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_existing_folder_is_not_rewritten(client, db_session, maker_user, validate_says):
|
||||||
|
validate_says()
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
first = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
|
||||||
|
marker = settings.upload_path / first["recipe_dir"] / "dxf" / "part.dxf"
|
||||||
|
marker.write_bytes(b"CHANGED")
|
||||||
|
second = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
|
||||||
|
assert second["recipe_dir"] == first["recipe_dir"]
|
||||||
|
assert marker.read_bytes() == b"CHANGED"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_requires_maker(
|
||||||
|
client, db_session, maker_user, measurement_tec_user, validate_says,
|
||||||
|
):
|
||||||
|
validate_says()
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await upload(client, measurement_tec_user, task, files=GOOD_FILES)
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_dxf_compare_saves_files_and_extended_response(
|
||||||
|
client, db_session, measurement_tec_user, monkeypatch,
|
||||||
|
):
|
||||||
|
task, subtask = await make_task(db_session, measurement_tec_user)
|
||||||
|
task.vision_json = RECIPE
|
||||||
|
task.vision_recipe_dir = "vision/recipes/1/abc"
|
||||||
|
subtask.vision_output = "cmp.points_in_pct"
|
||||||
|
await db_session.commit()
|
||||||
|
seen = {}
|
||||||
|
overlay = [{"type": "contour", "role": "found", "rows": [1], "cols": [2], "tool_id": "cmp"}]
|
||||||
|
|
||||||
|
async def fake_call(image_bytes, graph, **kwargs):
|
||||||
|
seen.update(kwargs)
|
||||||
|
return {
|
||||||
|
"outputs": {"cmp.points_in_pct": float(subtask.nominal)},
|
||||||
|
"failures": [], "engine_version": "e" * 40, "duration_ms": 5,
|
||||||
|
"overlay": overlay, "outcome": "OK",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||||
|
|
||||||
|
resp = 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 resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert seen["recipe_dir"] == "vision/recipes/1/abc" and "calibration" not in seen
|
||||||
|
assert body["outcome"] == "OK" and body["overlay"] == overlay and body["failures"] == []
|
||||||
|
assert body["image_url"] == f"/api/vision/results/{body['vision_result_id']}/image"
|
||||||
|
assert body["measurements"][0]["pass_fail"] == "pass"
|
||||||
|
|
||||||
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
assert (settings.upload_path / result.image_path).read_bytes() == an_image()
|
||||||
|
assert json.loads((settings.upload_path / result.overlay_path).read_text()) == overlay
|
||||||
|
|
||||||
|
img = await client.get(body["image_url"], headers=auth_headers(measurement_tec_user))
|
||||||
|
assert img.status_code == 200 and img.content == an_image()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_dxf_compare_out_of_tolerance_fails(
|
||||||
|
client, db_session, measurement_tec_user, monkeypatch,
|
||||||
|
):
|
||||||
|
task, subtask = await make_task(db_session, measurement_tec_user)
|
||||||
|
task.vision_json = RECIPE
|
||||||
|
subtask.vision_output = "cmp.points_in_pct"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
async def fake_call(image_bytes, graph, **kwargs):
|
||||||
|
return {"outputs": {"cmp.points_in_pct": 99.0}, "failures": [],
|
||||||
|
"engine_version": "e" * 40, "outcome": "OK"}
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||||
|
resp = 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 resp.status_code == 200
|
||||||
|
# il verdetto e' delle tolleranze del subtask, non di outcome di VisionSuite
|
||||||
|
assert resp.json()["measurements"][0]["pass_fail"] == "fail"
|
||||||
|
assert resp.json()["overlay"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_on_write_carries_recipe_dir(client, db_session, maker_user):
|
||||||
|
from src.backend.tests.test_vision_copy_on_write import (
|
||||||
|
_current_task_and_subtask, _seed_measurement,
|
||||||
|
)
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
task.task_type = "dxf_compare"
|
||||||
|
task.vision_json = RECIPE
|
||||||
|
task.vision_recipe_dir = "vision/recipes/1/abc"
|
||||||
|
await db_session.flush()
|
||||||
|
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||||
|
db_session.expunge_all()
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
|
||||||
|
json={"change_notes": "cow"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
db_session.expunge_all()
|
||||||
|
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
assert version.version_number == 2
|
||||||
|
assert new_task.vision_recipe_dir == "vision/recipes/1/abc" and new_task.vision_json == RECIPE
|
||||||
Reference in New Issue
Block a user