Files
Labvoice/tests/test_allocate.py
2026-08-30 20:44:00 +02:00

177 lines
6.0 KiB
Python

"""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