Spaces:
Running
Running
| """ | |
| Smoke test: real data pipeline end-to-end | |
| ========================================== | |
| Muat surplus_deficit_real.csv -> run_matching -> validasi MatchingReport. | |
| Prinsip: | |
| - Tidak assert golden numbers (rapuh terhadap perubahan constraints/scoring). | |
| - Assert structural / behavioral invariants: tidak crash, matches berupa list, | |
| confidence labels valid, volume tidak negatif, bawang_putih ditangani gracefully. | |
| - Default surplus_deficit.csv TIDAK digunakan di sini — load_real_data() only. | |
| """ | |
| from __future__ import annotations | |
| import sys | |
| import os | |
| import pathlib | |
| import pytest | |
| ROOT = pathlib.Path(__file__).parent.parent | |
| sys.path.insert(0, str(ROOT)) | |
| from matching_engine import run_matching, LogisticsContext, Confidence | |
| from sample_data import load_real_data | |
| # --------------------------------------------------------------------------- | |
| # Shared fixture: load real data + run engine once per module | |
| # --------------------------------------------------------------------------- | |
| def real_report(): | |
| """Run engine on real BPS data; shared across all tests in this module.""" | |
| data = load_real_data() | |
| logistics = LogisticsContext( | |
| bbm_price_idr_per_liter=10000, | |
| bbm_price_baseline=10000, | |
| ) | |
| report = run_matching( | |
| surplus_nodes=data["surplus"], | |
| deficit_nodes=data["deficit"], | |
| logistics=logistics, | |
| weather_forecasts=data["weather"], | |
| historical_prices=data["historical_prices"], | |
| ) | |
| return report | |
| def real_data(): | |
| """Raw data dict from load_real_data().""" | |
| return load_real_data() | |
| # --------------------------------------------------------------------------- | |
| # P1. Loader: load_real_data() tidak crash dan mengembalikan 6 komoditas | |
| # --------------------------------------------------------------------------- | |
| def test_load_real_data_no_crash(real_data): | |
| """load_real_data() harus berhasil dan mengisi semua keys.""" | |
| assert "surplus" in real_data | |
| assert "deficit" in real_data | |
| assert "kabupaten" in real_data | |
| assert "komoditas" in real_data | |
| def test_real_data_has_6_commodities(real_data): | |
| """Harus ada 6 komoditas real: beras_premium, beras_medium, cabai_merah, | |
| cabai_rawit, bawang_merah, bawang_putih.""" | |
| all_nodes = real_data["surplus"] + real_data["deficit"] | |
| codes = {n.commodity.code for n in all_nodes} | |
| expected = { | |
| "beras_premium", "beras_medium", | |
| "cabai_merah", "cabai_rawit", | |
| "bawang_merah", "bawang_putih", | |
| } | |
| assert codes == expected, f"Expected 6 real commodity codes, got: {codes}" | |
| def test_real_data_row_counts(real_data): | |
| """228 baris = 38 kab * 6 komoditas (beras 2 grades) — bagi surplus+deficit.""" | |
| total_nodes = len(real_data["surplus"]) + len(real_data["deficit"]) | |
| assert total_nodes == 228, ( | |
| f"Expected 228 total nodes from real CSV, got {total_nodes}" | |
| ) | |
| def test_load_real_data_does_not_alter_default(real_data): | |
| """Default load_all_sample_data() harus masih berupa 19-komoditas sintetis — | |
| dipastikan dengan cek bahwa real_data hanya berisi 6 komoditas.""" | |
| from sample_data import load_all_sample_data | |
| default_data = load_all_sample_data() | |
| default_codes = {n.commodity.code for n in default_data["surplus"] + default_data["deficit"]} | |
| real_codes = {n.commodity.code for n in real_data["surplus"] + real_data["deficit"]} | |
| # Default punya lebih banyak komoditas dari real | |
| assert len(default_codes) > len(real_codes), ( | |
| f"Default data seharusnya punya lebih banyak komoditas dari real. " | |
| f"default={default_codes}, real={real_codes}" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # P2. Engine tidak crash pada data real | |
| # --------------------------------------------------------------------------- | |
| def test_engine_does_not_crash(real_report): | |
| """run_matching pada data real tidak raise exception.""" | |
| assert real_report is not None | |
| def test_report_has_required_fields(real_report): | |
| """MatchingReport harus punya semua field yang diharapkan.""" | |
| assert hasattr(real_report, "matches") | |
| assert hasattr(real_report, "unmatched_surplus") | |
| assert hasattr(real_report, "unmatched_deficit") | |
| assert hasattr(real_report, "external_opportunities") | |
| assert hasattr(real_report, "warnings") | |
| assert hasattr(real_report, "run_metadata") | |
| def test_matches_is_list(real_report): | |
| """report.matches harus berupa list (bisa kosong tapi tidak None).""" | |
| assert isinstance(real_report.matches, list) | |
| def test_unmatched_fields_are_lists(real_report): | |
| """unmatched_surplus dan unmatched_deficit harus list.""" | |
| assert isinstance(real_report.unmatched_surplus, list) | |
| assert isinstance(real_report.unmatched_deficit, list) | |
| # --------------------------------------------------------------------------- | |
| # P3. Match structural validity | |
| # --------------------------------------------------------------------------- | |
| def test_no_negative_matched_volume(real_report): | |
| """Semua matched_volume_tons harus > 0.""" | |
| for m in real_report.matches: | |
| assert m.matched_volume_tons > 0, ( | |
| f"Negative/zero matched_volume: {m.surplus.kabupaten.nama} -> " | |
| f"{m.deficit.kabupaten.nama} {m.surplus.commodity.code}: " | |
| f"{m.matched_volume_tons}" | |
| ) | |
| def test_confidence_labels_valid(real_report): | |
| """Semua confidence harus berupa nilai Confidence enum yang valid.""" | |
| valid_confs = {Confidence.HIGH, Confidence.MEDIUM, Confidence.LOW} | |
| for m in real_report.matches: | |
| assert m.confidence in valid_confs, ( | |
| f"Invalid confidence value: {m.confidence!r}" | |
| ) | |
| def test_distance_km_positive(real_report): | |
| """Semua distance_km pada match harus > 0.""" | |
| for m in real_report.matches: | |
| assert m.distance_km > 0, ( | |
| f"Non-positive distance_km: {m.surplus.kabupaten.nama} -> " | |
| f"{m.deficit.kabupaten.nama}: {m.distance_km}" | |
| ) | |
| def test_final_score_positive(real_report): | |
| """final_score harus positif.""" | |
| for m in real_report.matches: | |
| assert m.final_score > 0, ( | |
| f"Non-positive final_score: {m.surplus.kabupaten.nama} -> " | |
| f"{m.deficit.kabupaten.nama}: {m.final_score}" | |
| ) | |
| def test_commodity_codes_same_per_match(real_report): | |
| """Setiap match (tanpa grade substitution): surplus.commodity == deficit.commodity.""" | |
| for m in real_report.matches: | |
| if m.flags and "GRADE_SUBSTITUTION" in m.flags: | |
| continue # grade sub diizinkan | |
| assert m.surplus.commodity.code == m.deficit.commodity.code, ( | |
| f"Commodity mismatch in match without GRADE_SUBSTITUTION flag: " | |
| f"{m.surplus.commodity.code} vs {m.deficit.commodity.code}" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # P4. bawang_putih all-deficit — engine harus handle gracefully | |
| # --------------------------------------------------------------------------- | |
| def test_bawang_putih_no_surplus_nodes(real_data): | |
| """Tidak ada surplus node bawang_putih dalam data real.""" | |
| bp_surplus = [s for s in real_data["surplus"] if s.commodity.code == "bawang_putih"] | |
| assert len(bp_surplus) == 0, ( | |
| f"Expected 0 bawang_putih surplus nodes, got {len(bp_surplus)}" | |
| ) | |
| def test_bawang_putih_zero_matches(real_report): | |
| """Karena tidak ada surplus bawang_putih, engine harus menghasilkan 0 matches.""" | |
| bp_matches = [ | |
| m for m in real_report.matches | |
| if m.surplus.commodity.code == "bawang_putih" | |
| or m.deficit.commodity.code == "bawang_putih" | |
| ] | |
| assert len(bp_matches) == 0, ( | |
| f"Expected 0 bawang_putih matches (all-deficit), got {len(bp_matches)}" | |
| ) | |
| def test_bawang_putih_handled_gracefully(real_report): | |
| """bawang_putih (all-deficit): semua 38 kab Jatim 2022 deficit. | |
| Engine harus handle gracefully — tidak crash, dan melaporkan bawang_putih | |
| melalui salah satu channel: warnings, external_opportunities, ATAU | |
| unmatched_deficit (ketika tidak ada surplus domestik sama sekali). | |
| Perilaku aktual (post data-prep fix): historical_price_stats.csv menggunakan | |
| real PIHPS 2022 median (20,750, std 3,518). Harga deficit node 20,750 = median | |
| -> z=0, tidak ada anomaly exclusion. Tidak ada surplus, jadi engine meletakkan | |
| semua 38 bawang_putih deficit nodes ke unmatched_deficit. | |
| Test ini memvalidasi perilaku graceful engine, bukan endpoint output spesifik. | |
| """ | |
| bp_warnings = [ | |
| w for w in real_report.warnings | |
| if "bawang_putih" in w.lower() or "Bawang Putih" in w | |
| ] | |
| bp_opps = [ | |
| o for o in real_report.external_opportunities | |
| if "bawang_putih" in o | |
| ] | |
| bp_unmatched = [ | |
| d for d in real_report.unmatched_deficit | |
| if d.commodity.code == "bawang_putih" | |
| ] | |
| # Engine harus melaporkan bawang_putih di salah satu channel | |
| # (warning, external_opp, atau unmatched_deficit — tergantung path engine) | |
| assert len(bp_warnings) > 0 or len(bp_opps) > 0 or len(bp_unmatched) > 0, ( | |
| "Engine harus melaporkan bawang_putih melalui warnings, " | |
| "external_opportunities, atau unmatched_deficit — tidak boleh diam. " | |
| f"warnings={bp_warnings}, opps={bp_opps}, " | |
| f"unmatched_deficit_count={len(bp_unmatched)}" | |
| ) | |
| def test_bawang_putih_zero_matches_and_no_crash(real_report, real_data): | |
| """bawang_putih: 0 matches, engine tidak crash. | |
| Sebab: tidak ada surplus domestik (all-deficit, 38 nodes). Engine reports | |
| via external_opportunities. Ini dokumentasi engine behavior. | |
| """ | |
| bp_matches = [ | |
| m for m in real_report.matches | |
| if m.surplus.commodity.code == "bawang_putih" | |
| or m.deficit.commodity.code == "bawang_putih" | |
| ] | |
| # 0 matches expected (either all-deficit OR price-anomaly-excluded) | |
| assert len(bp_matches) == 0, ( | |
| f"Expected 0 bawang_putih matches, got {len(bp_matches)}" | |
| ) | |
| # Engine returned a valid report — tidak crash | |
| assert real_report is not None | |
| # --------------------------------------------------------------------------- | |
| # P5. Sanity: at least some matches exist for commodities with real surplus | |
| # --------------------------------------------------------------------------- | |
| def test_has_some_matches(real_report): | |
| """Engine harus menghasilkan setidaknya 1 match dari 5 komoditas yang punya surplus.""" | |
| assert len(real_report.matches) >= 1, ( | |
| "Expected at least 1 match from real data " | |
| "(5 commodities have surplus: beras, cabai, bawang_merah)" | |
| ) | |
| def test_bawang_merah_has_matches(real_report): | |
| """bawang_merah punya surplus besar (Nganjuk 190k ton) — harus ada matches.""" | |
| bm_matches = [ | |
| m for m in real_report.matches | |
| if m.surplus.commodity.code == "bawang_merah" | |
| ] | |
| assert len(bm_matches) >= 1, ( | |
| "Expected at least 1 bawang_merah match " | |
| "(Nganjuk/Probolinggo surplus, many deficit kab)" | |
| ) | |
| def test_beras_has_matches(real_report): | |
| """beras_premium dan/atau beras_medium punya surplus besar — harus ada matches.""" | |
| beras_matches = [ | |
| m for m in real_report.matches | |
| if m.surplus.commodity.code in {"beras_premium", "beras_medium"} | |
| ] | |
| assert len(beras_matches) >= 1, ( | |
| "Expected at least 1 beras match " | |
| "(Lamongan/Ngawi/Bojonegoro surplus, Surabaya/kota deficit)" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # P6. run_metadata integrity | |
| # --------------------------------------------------------------------------- | |
| def test_metadata_latency_present(real_report): | |
| """run_metadata harus mengandung latency_ms.""" | |
| assert "latency_ms" in real_report.run_metadata | |
| assert real_report.run_metadata["latency_ms"] >= 0 | |
| def test_metadata_total_matches_consistent(real_report): | |
| """run_metadata['total_matches'] harus konsisten dengan len(report.matches).""" | |
| assert real_report.run_metadata["total_matches"] == len(real_report.matches) | |
| def test_metadata_candidate_pairs_present(real_report): | |
| """candidate_pairs_evaluated harus ada dan >= total_matches.""" | |
| meta = real_report.run_metadata | |
| assert "candidate_pairs_evaluated" in meta | |
| assert meta["candidate_pairs_evaluated"] >= meta["total_matches"] | |