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