| """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): |
| |
| 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): |
| |
| assert z_from_service_level(0.99) == pytest.approx(2.3263, abs=1e-3) |
|
|
| def test_tail_region(self): |
| |
| 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): |
| |
| 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): |
| |
| |
| 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): |
| |
| |
| |
| |
| |
| |
| |
| 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): |
| |
| 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): |
| |
| 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): |
| |
| 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): |
| |
| |
| 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) |
|
|