"""allocate.py — unit whitelist/conversion + allocation policies (plan §4, §7). Tested conversion pairs per the plan: mL↔L and the mass chain μg↔mg↔g↔kg; anything outside the whitelist or across dimensions must be a clarification, never a guess. """ import pytest from mcp_server.allocate import are_compatible, choose_containers, convert from mcp_server.errors import ClarificationNeededError, InsufficientStockError from mcp_server.models import AllocationPolicy, ContainerStock def stock(cid: int, available: float, unit: str = "mL") -> ContainerStock: return ContainerStock(container_id=cid, amount=f"{available} {unit}", available=available, unit=unit) # --- unit compatibility ----------------------------------------------------- @pytest.mark.parametrize( ("a", "b"), [("mL", "L"), ("L", "mL"), ("mg", "g"), ("g", "kg"), ("μg", "mg"), ("ea", "ea")], ) def test_compatible_within_dimension(a, b): assert are_compatible(a, b) @pytest.mark.parametrize( ("a", "b"), [("mL", "mg"), ("L", "ea"), ("ea", "mL"), ("g", "μL")], ) def test_incompatible_across_dimensions(a, b): assert not are_compatible(a, b) @pytest.mark.parametrize("unit", ["fl-oz", "cups", "", "mmol"]) def test_unknown_units_are_never_compatible(unit): assert not are_compatible(unit, "mL") assert not are_compatible("mL", unit) # --- conversion ------------------------------------------------------------- def test_volume_conversions(): assert convert(1.0, "L", "mL") == pytest.approx(1000.0) assert convert(500.0, "mL", "L") == pytest.approx(0.5) def test_mass_conversions(): assert convert(1.0, "g", "mg") == pytest.approx(1000.0) assert convert(1.0, "kg", "g") == pytest.approx(1000.0) assert convert(1000.0, "mg", "g") == pytest.approx(1.0) assert convert(1000.0, "μg", "mg") == pytest.approx(1.0) def test_count_is_identity(): assert convert(3.0, "ea", "ea") == 3.0 def test_cross_dimension_conversion_is_a_clarification(): with pytest.raises(ClarificationNeededError): convert(1.0, "mL", "mg") def test_unknown_unit_conversion_is_a_clarification(): with pytest.raises(ClarificationNeededError): convert(1.0, "fl-oz", "mL") # --- fifo allocation -------------------------------------------------------- def test_fifo_takes_from_lowest_container_id_with_stock(): allocations = choose_containers( [stock(45, 5.0), stock(31, 50.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO ) assert len(allocations) == 1 assert allocations[0].container_id == 31 assert allocations[0].take == pytest.approx(2.0) assert allocations[0].remaining == "48.0 mL" def test_fifo_splits_across_containers_when_first_is_short(): allocations = choose_containers( [stock(31, 1.5), stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO ) assert [a.container_id for a in allocations] == [31, 32] assert allocations[0].take == pytest.approx(1.5) assert allocations[1].take == pytest.approx(0.5) assert allocations[1].remaining == "9.5 mL" def test_fifo_skips_empty_containers(): allocations = choose_containers( [stock(31, 0.0), stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO ) assert [a.container_id for a in allocations] == [32] def test_fifo_exact_fit_leaves_container_empty(): allocations = choose_containers( [stock(31, 2.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO ) assert allocations[0].remaining == "0.0 mL" def test_insufficient_stock_raises_with_shortfall(): with pytest.raises(InsufficientStockError) as excinfo: choose_containers( [stock(31, 1.0), stock(32, 0.5)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO ) message = str(excinfo.value) assert "0.5" in message # 1.5 available vs 2.0 required → 0.5 short def test_insufficient_stock_with_no_containers_at_all(): with pytest.raises(InsufficientStockError): choose_containers([], required=2.0, unit="mL", policy=AllocationPolicy.FIFO) # --- unit conversion during allocation -------------------------------------- def test_allocation_converts_between_compatible_units(): """Required 0.5 L against a container stocked in mL.""" allocations = choose_containers( [stock(31, 1000.0, "mL")], required=0.5, unit="L", policy=AllocationPolicy.FIFO ) assert allocations[0].take == pytest.approx(500.0) assert allocations[0].remaining == "500.0 mL" def test_allocation_rejects_incompatible_container_units(): containers = [stock(31, 10.0, "mg")] with pytest.raises(ClarificationNeededError): choose_containers(containers, required=2.0, unit="mL", policy=AllocationPolicy.FIFO) # --- specific allocation ---------------------------------------------------- def test_specific_takes_only_from_the_listed_container(): """The caller pre-filters to the hinted container (consumable.container_id).""" allocations = choose_containers( [stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.SPECIFIC ) assert [a.container_id for a in allocations] == [32] def test_specific_with_unavailable_container_is_a_clarification(): with pytest.raises(ClarificationNeededError): choose_containers([], required=2.0, unit="mL", policy=AllocationPolicy.SPECIFIC) # --- nearest expiry (deferred per plan §15.2, kept as executable spec) ------ @pytest.mark.xfail(reason="nearest_expiry deferred per plan §15.2; expiry_field config pending") def test_nearest_expiry_picks_soonest_expiry_first(): soon = ContainerStock.model_construct( container_id=44, amount="5 mL", available=5.0, unit="mL", expires="2026-01-01" ) later = ContainerStock.model_construct( container_id=31, amount="5 mL", available=5.0, unit="mL", expires="2027-01-01" ) allocations = choose_containers( [later, soon], required=2.0, unit="mL", policy=AllocationPolicy.NEAREST_EXPIRY, expiry_field="Expiry date", ) assert allocations[0].container_id == 44