Spaces:
Running
Running
File size: 10,864 Bytes
b81a86b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | """
Unit tests Layer 2 (Multi-Objective Scoring).
Section 5.5.4 β bobot Distance 22% / Volume 22% / Price 22% / Perishability 18% / Climate 16%
"""
import pytest
from matching_engine.scoring import (
DEFAULT_WEIGHTS, RAMADAN_WEIGHTS, IMPORT_POLICY_WEIGHTS,
climate_score, compute_score, distance_score,
estimate_logistics_cost_per_kg, perishability_score,
price_score, volume_score,
)
from matching_engine.models import LogisticsContext, WeatherForecast
# =============================================================================
# DISTANCE SCORE
# =============================================================================
class TestDistanceScore:
def test_short_distance_high_score(self, surabaya, sidoarjo, cabai_merah, make_supply, make_demand):
s = make_supply(sidoarjo, cabai_merah)
d = make_demand(surabaya, cabai_merah)
score, dist = distance_score(s, d)
assert dist < 30 # Sidoarjo-Surabaya ~22 km
assert score > 0.85
def test_long_distance_low_score(self, pacitan, banyuwangi, beras_premium, make_supply, make_demand):
# Pacitan (barat) - Banyuwangi (timur) ~400km, max_distance beras = 800km
s = make_supply(pacitan, beras_premium)
d = make_demand(banyuwangi, beras_premium)
score, dist = distance_score(s, d)
assert dist > 350
assert score < 0.6 # ~0.5 expected
def test_score_is_zero_at_max_distance(self, surabaya, banyuwangi, cabai_merah, make_supply, make_demand):
s = make_supply(banyuwangi, cabai_merah) # ~280km > max 200km
d = make_demand(surabaya, cabai_merah)
score, _ = distance_score(s, d)
assert score == 0.0
# =============================================================================
# VOLUME SCORE
# =============================================================================
class TestVolumeScore:
def test_perfect_match(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
s = make_supply(kediri_kab, cabai_merah, volume=50)
d = make_demand(surabaya, cabai_merah, volume=50)
assert volume_score(s, d) == 1.0
def test_drastic_mismatch_low_score(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
# Skenario A3: 5 ton supply vs 100 ton demand
s = make_supply(kediri_kab, cabai_merah, volume=5)
d = make_demand(surabaya, cabai_merah, volume=100)
score = volume_score(s, d)
assert score == 0.05 # 5/100
def test_supply_larger_than_demand_full_coverage(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
# v11 fix: surplus 200t > demand 50t β demand fully covered (50/50 = 1.0).
# Excess surplus (150t) akan di-split ke deficit lain di Layer 3 greedy.
# Old semantics (min/max β 0.25) punished big-producer matches β
# not aligned dengan Indonesian supply chain reality.
s = make_supply(kediri_kab, cabai_merah, volume=200)
d = make_demand(surabaya, cabai_merah, volume=50)
assert volume_score(s, d) == pytest.approx(1.0)
def test_supply_partial_coverage(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
# Supply < demand β partial coverage.
# 30t supply / 50t demand = 0.6 (60% of demand satisfied by this source).
s = make_supply(kediri_kab, cabai_merah, volume=30)
d = make_demand(surabaya, cabai_merah, volume=50)
assert volume_score(s, d) == pytest.approx(0.6)
# =============================================================================
# PRICE SCORE & LOGISTICS COST
# =============================================================================
class TestPriceScore:
def test_arbitrage_positive_high_score(self, surabaya, kediri_kab, cabai_merah,
make_supply, make_demand, logistics_normal):
# 30 β 60 ribu (selisih 100%) jarak ~70km, harus score tinggi
s = make_supply(kediri_kab, cabai_merah, price=30000)
d = make_demand(surabaya, cabai_merah, price=60000)
score = price_score(s, d, distance_km=70, logistics=logistics_normal)
assert score > 0.9 # arbitrage net >50%
def test_arbitrage_negative_zero_score(self, surabaya, kediri_kab, cabai_merah,
make_supply, make_demand, logistics_normal):
# Demand < supply β lossy
s = make_supply(kediri_kab, cabai_merah, price=50000)
d = make_demand(surabaya, cabai_merah, price=40000)
score = price_score(s, d, distance_km=70, logistics=logistics_normal)
assert score == 0.0
def test_logistics_cost_per_kg_increases_with_distance(self, logistics_normal):
cost_short = estimate_logistics_cost_per_kg(50, logistics_normal)
cost_long = estimate_logistics_cost_per_kg(500, logistics_normal)
assert cost_long > cost_short
def test_bbm_naik_increases_cost(self, logistics_normal, logistics_bbm_naik_20pct):
# Skenario E5
cost_normal = estimate_logistics_cost_per_kg(200, logistics_normal)
cost_bbm_naik = estimate_logistics_cost_per_kg(200, logistics_bbm_naik_20pct)
assert cost_bbm_naik > cost_normal
# =============================================================================
# PERISHABILITY SCORE
# =============================================================================
class TestPerishabilityScore:
def test_fresh_short_transit_high_score(self, surabaya, kediri_kab, cabai_merah,
make_supply, make_demand, logistics_normal):
# cabai max_fresh=5 hari, age=0, transit jarak pendek β margin ~4.9 hari
# Score = min(1.0, 4.9/5.0) = ~0.98
s = make_supply(kediri_kab, cabai_merah, age=0)
d = make_demand(surabaya, cabai_merah)
score = perishability_score(s, d, distance_km=50, logistics=logistics_normal)
assert score > 0.9 # near maksimum
def test_too_old_zero_score(self, surabaya, kediri_kab, cabai_merah,
make_supply, make_demand, logistics_normal):
# cabai max_fresh = 5 hari, age=4, transit ~1 hari, sisa 0 hari
s = make_supply(kediri_kab, cabai_merah, age=4)
d = make_demand(surabaya, cabai_merah)
score = perishability_score(s, d, distance_km=70, logistics=logistics_normal)
assert score == 0.0 # margin < 1 hari
def test_beras_long_shelf_always_high(self, surabaya, kediri_kab, beras_premium,
make_supply, make_demand, logistics_normal):
s = make_supply(kediri_kab, beras_premium, age=0)
d = make_demand(surabaya, beras_premium)
score = perishability_score(s, d, distance_km=200, logistics=logistics_normal)
assert score == 1.0
# =============================================================================
# CLIMATE SCORE
# =============================================================================
class TestClimateScore:
def test_no_forecast_neutral(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
s = make_supply(kediri_kab, cabai_merah)
d = make_demand(surabaya, cabai_merah)
assert climate_score(s, d, weather=None) == 0.7
def test_clear_weather_full_score(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
s = make_supply(kediri_kab, cabai_merah)
d = make_demand(surabaya, cabai_merah)
wf = WeatherForecast(origin_kab_id="3506", dest_kab_id="3578", max_rain_mm=5.0)
assert climate_score(s, d, weather=wf) == 1.0
def test_heavy_rain_low_score(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
# Skenario D1
s = make_supply(kediri_kab, cabai_merah)
d = make_demand(surabaya, cabai_merah)
wf = WeatherForecast(origin_kab_id="3506", dest_kab_id="3578", max_rain_mm=75.0)
assert climate_score(s, d, weather=wf) == 0.3
def test_medium_rain_mid_score(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
s = make_supply(kediri_kab, cabai_merah)
d = make_demand(surabaya, cabai_merah)
wf = WeatherForecast(origin_kab_id="3506", dest_kab_id="3578", max_rain_mm=30.0)
assert climate_score(s, d, weather=wf) == 0.6
# =============================================================================
# COMPUTE SCORE β full weighted total
# =============================================================================
class TestComputeScore:
def test_default_weights_sum_to_one(self):
assert sum(DEFAULT_WEIGHTS.values()) == pytest.approx(1.0)
def test_ramadan_weights_sum_to_one(self):
assert sum(RAMADAN_WEIGHTS.values()) == pytest.approx(1.0)
def test_import_policy_weights_sum_to_one(self):
assert sum(IMPORT_POLICY_WEIGHTS.values()) == pytest.approx(1.0)
def test_default_weights_match_section_5_5_4(self):
# Bobot Section 5.5.4: 22/22/22/18/16
assert DEFAULT_WEIGHTS["distance"] == 0.22
assert DEFAULT_WEIGHTS["volume"] == 0.22
assert DEFAULT_WEIGHTS["price"] == 0.22
assert DEFAULT_WEIGHTS["perishability"] == 0.18
assert DEFAULT_WEIGHTS["climate"] == 0.16
def test_compute_score_returns_breakdown_and_total(self, surabaya, kediri_kab, cabai_merah,
make_supply, make_demand, logistics_normal):
s = make_supply(kediri_kab, cabai_merah, volume=50, price=30000, age=0)
d = make_demand(surabaya, cabai_merah, volume=50, price=60000)
breakdown, base, dist = compute_score(s, d, logistics=logistics_normal)
# All 5 dims should be > 0 for a healthy match
assert breakdown.distance > 0
assert breakdown.volume == 1.0
assert breakdown.price > 0.5
assert breakdown.perishability > 0.9 # cabai 5d shelf, near max
assert 0 < base <= 100
def test_ramadan_weights_increase_perishability_importance(self, surabaya, kediri_kab,
cabai_merah, make_supply, make_demand,
logistics_normal):
# Skenario C1: bobot perishability naik dari 0.18 ke 0.22
s = make_supply(kediri_kab, cabai_merah, age=2)
d = make_demand(surabaya, cabai_merah)
_, base_default, _ = compute_score(s, d, logistics=logistics_normal,
weights=DEFAULT_WEIGHTS)
_, base_ramadan, _ = compute_score(s, d, logistics=logistics_normal,
weights=RAMADAN_WEIGHTS)
# Score harus berbeda (perishability lebih dominan)
assert base_default != base_ramadan
|