Spaces:
Running
Running
| """ | |
| 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 | |