177 lines
6.0 KiB
Python
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
|