"""Hand-checked unit tests for the safety-stock / reorder-point math. Every expected value below was computed by hand from the formulas in app/inventory_math.py — see the arithmetic in each test's comments. """ import math import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from app.inventory_math import ( Z_90, demand_std_from_quantiles, recommend, z_from_service_level, ) class TestZFromServiceLevel: def test_median_is_zero(self): assert z_from_service_level(0.5) == pytest.approx(0.0, abs=1e-8) def test_95_percent(self): # Phi^-1(0.95) = 1.6449 (standard normal table) assert z_from_service_level(0.95) == pytest.approx(1.6449, abs=1e-3) def test_90_percent_matches_Z_90_constant(self): assert z_from_service_level(0.90) == pytest.approx(Z_90, abs=1e-3) def test_99_percent(self): # Phi^-1(0.99) = 2.3263 assert z_from_service_level(0.99) == pytest.approx(2.3263, abs=1e-3) def test_tail_region(self): # 0.999 falls in the approximation's upper tail branch; Phi^-1(0.999) = 3.0902 assert z_from_service_level(0.999) == pytest.approx(3.0902, abs=1e-3) @pytest.mark.parametrize("bad", [0.0, 1.0, -0.5, 1.5]) def test_rejects_out_of_range(self, bad): with pytest.raises(ValueError): z_from_service_level(bad) class TestDemandStd: def test_constant_spread(self): # spread = 12.816 - 10 = 2.816 every day; 2.816 / 1.2816 = 2.1972... p50 = [10.0, 10.0, 10.0] p90 = [12.816, 12.816, 12.816] assert demand_std_from_quantiles(p50, p90) == pytest.approx(2.816 / 1.2816, rel=1e-9) def test_zero_spread_means_zero_std(self): assert demand_std_from_quantiles([5.0, 5.0], [5.0, 5.0]) == 0.0 def test_negative_spread_clamped_to_zero(self): # crossed quantiles (can happen with independently trained models) never # produce a negative std assert demand_std_from_quantiles([10.0], [8.0]) == 0.0 def test_rejects_length_mismatch(self): with pytest.raises(ValueError): demand_std_from_quantiles([1.0, 2.0], [1.0]) class TestRecommend: def test_hand_checked_full_example(self): # 7-day horizon, p50 = 10/day, p90 = 12.5632/day everywhere: # avg_daily_demand = 10 # demand_std = (12.5632 - 10) / 1.2816 = 2.0 # z(0.95) = 1.6449 # safety_stock = 1.6449 * 2.0 * sqrt(7) = 8.7040... # reorder_point = 10 * 7 + 8.7040 = 78.7040... # order_qty (inventory 20) = 78.7040 - 20 = 58.7040... p50 = [10.0] * 7 p90 = [12.5632] * 7 rec = recommend(p50, p90, current_inventory=20.0, service_level=0.95, lead_time_days=7) expected_ss = 1.6449 * 2.0 * math.sqrt(7) assert rec.avg_daily_demand == pytest.approx(10.0) assert rec.demand_std == pytest.approx(2.0, rel=1e-9) assert rec.safety_stock == pytest.approx(expected_ss, abs=2e-3) assert rec.reorder_point == pytest.approx(70.0 + expected_ss, abs=2e-3) assert rec.suggested_order_qty == pytest.approx(50.0 + expected_ss, abs=2e-3) def test_zero_uncertainty_reduces_to_deterministic_reorder(self): # p90 == p50 -> no safety stock; reorder point is just demand * lead time rec = recommend([4.0] * 7, [4.0] * 7, current_inventory=0.0, service_level=0.95, lead_time_days=7) assert rec.safety_stock == 0.0 assert rec.reorder_point == pytest.approx(28.0) assert rec.suggested_order_qty == pytest.approx(28.0) def test_order_qty_floors_at_zero(self): # plenty of inventory -> no order, never negative rec = recommend([1.0] * 7, [1.5] * 7, current_inventory=1000.0) assert rec.suggested_order_qty == 0.0 def test_only_lead_time_days_are_used(self): # extra forecast days beyond the lead time must not affect the answer base = recommend([10.0] * 7, [12.0] * 7, lead_time_days=7) extended = recommend([10.0] * 7 + [999.0] * 21, [12.0] * 7 + [999.0] * 21, lead_time_days=7) assert base.reorder_point == extended.reorder_point def test_lead_time_scaling(self): # lead time 4 with constant demand: reorder = 5*4 + z*std*sqrt(4) = 20 + 2*z*std # p90-p50 = 1.2816 -> std = 1.0 exactly; z(0.90) = 1.2816 rec = recommend([5.0] * 4, [6.2816] * 4, current_inventory=0.0, service_level=0.90, lead_time_days=4) assert rec.demand_std == pytest.approx(1.0, rel=1e-9) assert rec.safety_stock == pytest.approx(1.2816 * 1.0 * 2.0, abs=2e-3) assert rec.reorder_point == pytest.approx(20.0 + 2.5632, abs=2e-3) def test_rejects_short_forecast(self): with pytest.raises(ValueError): recommend([1.0] * 3, [2.0] * 3, lead_time_days=7) def test_rejects_bad_lead_time(self): with pytest.raises(ValueError): recommend([1.0] * 7, [2.0] * 7, lead_time_days=0)