"""What a recipe is allowed to demand of the operator (points 8 and 9). Two rules that used to be nobody's: whether the lot and the serial are compulsory, and whether a value may be typed rather than read from the caliper. Both are declared on the recipe and enforced on the server, because a rule enforced only by the screen is a suggestion - the keypad can be hidden and the same request still sent by hand. """ import pytest from httpx import AsyncClient from sqlalchemy import select from src.backend.models.orm.recipe import Recipe, RecipeVersion from src.backend.models.orm.station import Station from src.backend.models.orm.task import RecipeSubtask, RecipeTask from src.backend.tests.conftest import auth_headers, create_test_recipe async def _rules(db_session, recipe: Recipe, **flags) -> Recipe: for key, value in flags.items(): setattr(recipe, key, value) await db_session.commit() await db_session.refresh(recipe) return recipe async def _first_subtask(db_session, recipe_id: int) -> RecipeSubtask: row = await db_session.execute( select(RecipeSubtask) .join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id) .join(RecipeVersion, RecipeVersion.id == RecipeTask.version_id) .where(RecipeVersion.recipe_id == recipe_id) ) return row.scalars().first() async def _current_version_id(db_session, recipe_id: int) -> int: row = await db_session.execute( select(RecipeVersion).where( RecipeVersion.recipe_id == recipe_id, RecipeVersion.is_current == True, # noqa: E712 ) ) return row.scalar_one().id async def _measure(client, user, subtask_id, version_id, **extra): return await client.post( "/api/measurements/", headers=auth_headers(user), json={ "subtask_id": subtask_id, "version_id": version_id, "value": 10.0, **extra, }, ) # --------------------------------------------------------------------------- # The rules are part of the recipe # --------------------------------------------------------------------------- async def test_new_recipe_forbids_typing_by_default(client: AsyncClient, maker_user): """The caliper is the instrument: typing is what has to be asked for.""" resp = await client.post( "/api/recipes", headers=auth_headers(maker_user), json={"code": "REG-DEF", "name": "Regole di default"}, ) assert resp.status_code == 201, resp.text body = resp.json() assert body["allow_manual_input"] is False assert body["requires_lot"] is False assert body["requires_serial"] is False async def test_recipe_carries_the_rules_it_was_created_with( client: AsyncClient, maker_user, ): resp = await client.post( "/api/recipes", headers=auth_headers(maker_user), json={ "code": "REG-SET", "name": "Con regole", "requires_lot": True, "requires_serial": True, "allow_manual_input": True, }, ) body = resp.json() assert (body["requires_lot"], body["requires_serial"], body["allow_manual_input"]) \ == (True, True, True) async def test_rules_can_be_switched_off_again( client: AsyncClient, maker_user, db_session, ): """False must reach the database: a flag that only ever turns on is a trap.""" recipe = await create_test_recipe(db_session, maker_user.id, code="REG-OFF") await _rules(db_session, recipe, requires_lot=True, allow_manual_input=True) resp = await client.put( f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user), json={"requires_lot": False, "allow_manual_input": False}, ) assert resp.status_code == 200, resp.text assert resp.json()["requires_lot"] is False assert resp.json()["allow_manual_input"] is False async def test_rules_survive_a_new_version( client: AsyncClient, maker_user, measurement_tec_user, db_session, ): """Editing a recipe with measurements copies it; the rules must come along.""" recipe = await create_test_recipe(db_session, maker_user.id, code="REG-VER") await _rules(db_session, recipe, requires_lot=True) subtask = await _first_subtask(db_session, recipe.id) version_id = await _current_version_id(db_session, recipe.id) # A measurement on the current version forces copy-on-write on the next edit. await _measure( client, measurement_tec_user, subtask.id, version_id, lot_number="L-1", ) resp = await client.put( f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user), json={"name": "Rinominata"}, ) assert resp.status_code == 200 assert resp.json()["requires_lot"] is True # --------------------------------------------------------------------------- # Point 8 - traceability # --------------------------------------------------------------------------- async def test_measurement_without_the_required_lot_is_refused( client: AsyncClient, maker_user, measurement_tec_user, db_session, ): recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-LOT") await _rules(db_session, recipe, requires_lot=True) subtask = await _first_subtask(db_session, recipe.id) version_id = await _current_version_id(db_session, recipe.id) refused = await _measure(client, measurement_tec_user, subtask.id, version_id) assert refused.status_code == 422 # Blank is not a lot number either. blank = await _measure( client, measurement_tec_user, subtask.id, version_id, lot_number=" ", ) assert blank.status_code == 422 accepted = await _measure( client, measurement_tec_user, subtask.id, version_id, lot_number="LOT-9", ) assert accepted.status_code == 200 async def test_measurement_without_the_required_serial_is_refused( client: AsyncClient, maker_user, measurement_tec_user, db_session, ): recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-SER") await _rules(db_session, recipe, requires_serial=True) subtask = await _first_subtask(db_session, recipe.id) version_id = await _current_version_id(db_session, recipe.id) assert (await _measure( client, measurement_tec_user, subtask.id, version_id, )).status_code == 422 assert (await _measure( client, measurement_tec_user, subtask.id, version_id, serial_number="SN-1", )).status_code == 200 async def test_optional_traceability_still_accepts_nothing( client: AsyncClient, maker_user, measurement_tec_user, db_session, ): """Recipes that do not ask for a lot must go on working as they did.""" recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-OPT") subtask = await _first_subtask(db_session, recipe.id) version_id = await _current_version_id(db_session, recipe.id) assert (await _measure( client, measurement_tec_user, subtask.id, version_id, )).status_code == 200 async def test_production_cannot_start_without_the_required_lot( client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session, ): """The lot has to be there before the first value, not after it.""" recipe = await create_test_recipe(db_session, maker_user.id, code="TRC-RUN") await _rules(db_session, recipe, requires_lot=True) station = Station( code="ST-TRC", name="Stazione", active=True, created_by=admin_user.id, ) db_session.add(station) await db_session.commit() refused = await client.post( "/api/production-runs", headers=auth_headers(measurement_tec_user), json={"station_code": station.code, "recipe_id": recipe.id}, ) assert refused.status_code == 422 started = await client.post( "/api/production-runs", headers=auth_headers(measurement_tec_user), json={ "station_code": station.code, "recipe_id": recipe.id, "lot_number": "L-7", }, ) assert started.status_code == 201 # --------------------------------------------------------------------------- # Point 9 - typed values # --------------------------------------------------------------------------- async def test_typed_value_is_refused_when_the_recipe_forbids_it( client: AsyncClient, maker_user, measurement_tec_user, db_session, ): recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-NO") await _rules(db_session, recipe, allow_manual_input=False) subtask = await _first_subtask(db_session, recipe.id) version_id = await _current_version_id(db_session, recipe.id) refused = await _measure( client, measurement_tec_user, subtask.id, version_id, input_method="manual", ) assert refused.status_code == 422 assert "caliper" in refused.json()["detail"].lower() async def test_the_caliper_is_always_welcome( client: AsyncClient, maker_user, measurement_tec_user, db_session, ): recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-USB") await _rules(db_session, recipe, allow_manual_input=False) subtask = await _first_subtask(db_session, recipe.id) version_id = await _current_version_id(db_session, recipe.id) resp = await _measure( client, measurement_tec_user, subtask.id, version_id, input_method="usb_caliper", ) assert resp.status_code == 200 assert resp.json()["input_method"] == "usb_caliper" async def test_typing_is_allowed_where_the_recipe_says_so( client: AsyncClient, maker_user, measurement_tec_user, db_session, ): recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-YES") subtask = await _first_subtask(db_session, recipe.id) version_id = await _current_version_id(db_session, recipe.id) resp = await _measure( client, measurement_tec_user, subtask.id, version_id, input_method="manual", ) assert resp.status_code == 200 async def test_the_default_input_method_is_covered_by_the_rule( client: AsyncClient, maker_user, measurement_tec_user, db_session, ): """A request that says nothing about how the value was entered counts as typed. Otherwise omitting the field would be the way around the rule. """ recipe = await create_test_recipe(db_session, maker_user.id, code="MAN-DEF") await _rules(db_session, recipe, allow_manual_input=False) subtask = await _first_subtask(db_session, recipe.id) version_id = await _current_version_id(db_session, recipe.id) assert (await _measure( client, measurement_tec_user, subtask.id, version_id, )).status_code == 422