Spaces:
Sleeping
Sleeping
| """Smoke + behavior tests for the AgroSense RAG core. | |
| Run: python -m pytest (or) python tests/test_rag.py | |
| These run fully offline against the hashing embedder + numpy vector store. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| from pathlib import Path | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| os.environ.setdefault("AGROSENSE_EMBEDDING_BACKEND", "hashing") # deterministic, offline | |
| from agrosense import RAGEngine | |
| from agrosense.embeddings import HashingEmbedder | |
| def test_hashing_embedder_is_normalized(): | |
| emb = HashingEmbedder(dim=128) | |
| vecs = emb.encode(["sandy soil maize fertilizer", "paddy disease control"]) | |
| norms = (vecs ** 2).sum(axis=1) ** 0.5 | |
| assert vecs.shape == (2, 128) | |
| assert all(abs(n - 1.0) < 1e-5 for n in norms) | |
| def _engine() -> RAGEngine: | |
| return RAGEngine() | |
| def test_engine_indexes_kb(): | |
| assert _engine().num_documents >= 5 | |
| def test_maize_query_retrieves_maize_and_cites(): | |
| ans = _engine().answer( | |
| "My soil is sandy, rainfall 900 mm, I want to grow maize. " | |
| "What fertilizer and pest management should I follow?" | |
| ) | |
| crops = {c.crop for c in ans.citations} | |
| assert "Maize" in crops, f"expected Maize in citations, got {crops}" | |
| assert ans.citations, "answer must include at least one citation" | |
| assert "Sources" in ans.text | |
| def test_answer_is_grounded_no_empty(): | |
| ans = _engine().answer("fertilizer schedule for mint aromatic crop") | |
| assert "Mint" in {c.crop for c in ans.citations} | |
| assert "50:75:50" in ans.text # the exact basal dose from the KB | |
| def test_irrelevant_query_returns_fallback_not_fabrication(): | |
| ans = _engine().answer("what is the capital of France") | |
| # An off-domain question must NOT produce confident crop citations. | |
| assert ans.citations == [], f"expected no citations, got {ans.citations}" | |
| assert "could not find relevant guidance" in ans.text.lower() | |
| def test_gate_rejects_label_word_only_query(): | |
| # "fertilizer" is a field LABEL but appears in no field VALUE; a query that | |
| # only matches on the label must not return fabricated crop advice. | |
| ans = _engine().answer("fertilizer advice for my spaceship") | |
| assert ans.citations == [], f"expected no citations, got {ans.citations}" | |
| assert "could not find relevant guidance" in ans.text.lower() | |
| def test_weather_advisories_are_grounded_in_numbers(): | |
| # Build a forecast by hand (no network) and check advisories follow the data. | |
| from agrosense.weather import DailyWeather, WeatherForecast | |
| wf = WeatherForecast( | |
| location_name="Test", latitude=0.0, longitude=0.0, | |
| current_temp_c=30.0, current_precip_mm=0.0, current_humidity=50.0, | |
| daily=[ | |
| DailyWeather("2026-06-01", tmax_c=39.0, tmin_c=24.0, precip_mm=0.0, precip_prob=10.0), | |
| DailyWeather("2026-06-02", tmax_c=35.0, tmin_c=23.0, precip_mm=20.0, precip_prob=80.0), | |
| ], | |
| ) | |
| advisories = " ".join(wf.advisories()).lower() | |
| assert "rain likely" in advisories # because day 2 has 80% / 20mm | |
| assert "high daytime temperatures" in advisories # because day 1 tmax 39 | |
| assert "Local weather" in wf.to_context() | |
| def test_weather_offline_returns_none_not_error(): | |
| # No location -> engine attaches no weather and never raises. | |
| ans = _engine().answer("paddy disease control", location=None) | |
| assert ans.weather is None | |
| assert "Sources" in ans.text | |
| def test_engine_answer_accepts_location_param_gracefully(): | |
| # A location is requested; offline this returns None weather but still answers | |
| # from the KB. (When online, weather is attached — covered by the live check.) | |
| ans = _engine().answer("fertilizer for maize", location="Nowhere-Place-XYZ-123") | |
| assert isinstance(ans.to_dict(), dict) | |
| assert ans.citations # KB answer is unaffected by weather availability | |
| def test_satellite_imagery_urls_built_offline(): | |
| # URL construction needs no network and must produce valid GIBS WMS links. | |
| from datetime import date | |
| from agrosense.satellite import SatelliteClient | |
| client = SatelliteClient(buffer_deg=0.25) | |
| img = client.imagery(15.85, 74.50, today=date(2026, 5, 29)) | |
| urls = img["imagery"] | |
| assert "MODIS_Terra_CorrectedReflectance_TrueColor" in urls["true_color"] | |
| assert "MODIS_Terra_NDVI_8Day" in urls["ndvi"] | |
| assert "BBOX=15.6,74.25,16.1,74.75" in urls["true_color"] # lat-buf,lon-buf,... | |
| assert img["ndvi_date"] == "2026-05-11" # today - 18 days | |
| assert "worldview.earthdata.nasa.gov" in urls["worldview"] | |
| def test_agroclimate_notes_grounded_and_filter_fill_values(): | |
| from agrosense.satellite import AgroClimate, _avg | |
| # NASA POWER fill value (-999) must be excluded from averages. | |
| assert _avg([20.0, -999.0, 22.0]) == 21.0 | |
| ac = AgroClimate(start="2026-04-15", end="2026-05-15", days=30, | |
| avg_solar_mj=22.0, avg_tmax_c=33.0, avg_tmin_c=22.0, | |
| total_precip_mm=2.0) | |
| notes = " ".join(ac.notes()).lower() | |
| assert "ample solar radiation" in notes # solar 22 >= 20 | |
| assert "little rainfall" in notes # 2 mm over 30 days | |
| def test_satellite_offline_and_flag_off_attach_nothing(): | |
| # include_satellite defaults False -> no satellite work, no network. | |
| ans = _engine().answer("maize fertilizer", location=None) | |
| assert ans.satellite is None | |
| # --- field-level numeric NDVI (Earth Engine seam), mock-tested without creds --- | |
| _EE_SAMPLE = { | |
| "type": "FeatureCollection", | |
| "features": [ | |
| {"properties": {"date": "2026-05-20", "ndvi": 0.71, "cloud": 5}}, | |
| {"properties": {"date": "2026-05-01", "ndvi": 0.52, "cloud": 12}}, | |
| {"properties": {"date": "2026-05-10", "ndvi": None, "cloud": 90}}, # skipped | |
| {"properties": {"date": "2026-05-15", "ndvi": 0.66, "cloud": 8}}, | |
| ], | |
| } | |
| def test_ndvi_parse_skips_nulls_and_sorts(): | |
| from agrosense.ndvi import parse_ee_features | |
| obs = parse_ee_features(_EE_SAMPLE) | |
| assert [o.date for o in obs] == ["2026-05-01", "2026-05-15", "2026-05-20"] | |
| assert [o.ndvi for o in obs] == [0.52, 0.66, 0.71] | |
| def test_ndvi_summarize_trend_status_and_notes(): | |
| from agrosense.ndvi import NDVIResult, parse_ee_features, summarize, classify_ndvi | |
| assert classify_ndvi(0.7) == "healthy/dense" | |
| assert classify_ndvi(0.4) == "moderate" | |
| assert classify_ndvi(0.1) == "sparse/stressed" | |
| res = NDVIResult(location_name="Field", latitude=15.0, longitude=74.0, | |
| buffer_m=100, observations=parse_ee_features(_EE_SAMPLE)) | |
| summarize(res) | |
| assert res.latest == 0.71 and res.latest_date == "2026-05-20" | |
| assert res.status == "healthy/dense" | |
| assert res.trend == "rising" # 0.52 early -> 0.66/0.71 late | |
| notes = " ".join(res.notes()).lower() | |
| assert "dense and healthy" in notes and "trending up" in notes | |
| def test_ndvi_provider_disabled_without_credentials(): | |
| # With no EE config (auto backend), the provider must not activate -> graceful None. | |
| import importlib | |
| from agrosense import config, ndvi | |
| assert config.NDVI_BACKEND == "auto" and not config.earthengine_configured() | |
| assert ndvi.get_ndvi_provider() is None | |
| assert ndvi.EarthEngineNDVIProvider().available() is False | |
| importlib.reload(ndvi) # leave module clean | |
| class _FakeNDVIProvider: | |
| """Stands in for Earth Engine so the seam can be tested without creds/network.""" | |
| def fetch(self, lat, lon, location_name="", **kw): | |
| from agrosense.ndvi import NDVIResult, parse_ee_features, summarize | |
| res = NDVIResult(location_name=location_name, latitude=lat, longitude=lon, | |
| buffer_m=100, observations=parse_ee_features(_EE_SAMPLE)) | |
| return summarize(res) | |
| def test_satellite_client_uses_injected_ndvi_provider(): | |
| from agrosense.satellite import SatelliteClient | |
| client = SatelliteClient(ndvi_provider=_FakeNDVIProvider()) | |
| nd = client.numeric_ndvi(15.85, 74.50, location_name="Test Field") | |
| assert nd is not None and nd.latest == 0.71 and nd.status == "healthy/dense" | |
| # And it must serialize + appear in the satellite context text. | |
| from agrosense.satellite import SatelliteReport | |
| rep = SatelliteReport(location_name="Test", latitude=15.85, longitude=74.50, | |
| truecolor_date="2026-05-26", ndvi_date="2026-05-11", | |
| imagery={"true_color": "x", "ndvi": "y"}, numeric_ndvi=nd) | |
| d = rep.to_dict() | |
| assert d["numeric_ndvi"]["latest"] == 0.71 | |
| assert "Field NDVI" in rep.to_context() | |
| def test_satellite_client_no_provider_returns_none(): | |
| from agrosense.satellite import SatelliteClient | |
| client = SatelliteClient(ndvi_provider=None) | |
| assert client.numeric_ndvi(15.85, 74.50) is None | |
| class _FakeMask: | |
| """Records the SCL classes a 'keep' mask excludes, via And-chaining.""" | |
| def __init__(self, excluded): | |
| self.excluded = set(excluded) | |
| def And(self, other): # noqa: N802 - mirrors the Earth Engine API name | |
| return _FakeMask(self.excluded | other.excluded) | |
| class _FakeSCL: | |
| def neq(self, cls): | |
| return _FakeMask([cls]) | |
| class _FakeImage: | |
| def __init__(self): | |
| self.masked_with = None | |
| def select(self, band): | |
| assert band == "SCL" | |
| return _FakeSCL() | |
| def updateMask(self, mask): # noqa: N802 - mirrors the Earth Engine API name | |
| self.masked_with = mask | |
| return self | |
| def test_scl_cloud_mask_excludes_exactly_intended_classes(): | |
| # Verifies the per-pixel cloud-mask composition WITHOUT Earth Engine: the | |
| # built mask must exclude exactly cloud/shadow/cirrus/snow SCL classes. | |
| from agrosense.ndvi import S2_CLOUD_SCL_CLASSES, apply_cloud_mask, build_scl_mask | |
| assert S2_CLOUD_SCL_CLASSES == (3, 8, 9, 10, 11) | |
| mask = build_scl_mask(_FakeSCL()) | |
| assert mask.excluded == {3, 8, 9, 10, 11} | |
| img = _FakeImage() | |
| out = apply_cloud_mask(img) | |
| assert out is img and img.masked_with.excluded == {3, 8, 9, 10, 11} | |
| # --- multilingual (translation), mock-tested without a translation backend --- | |
| class _FakeTranslator: | |
| name = "fake" | |
| def translate(self, text, source, target): | |
| return f"[{target}] {text}" | |
| class _RaisingTranslator: | |
| name = "boom" | |
| def translate(self, text, source, target): | |
| raise RuntimeError("no backend") | |
| def test_supported_languages_cover_report_phase2(): | |
| from agrosense.translation import SUPPORTED_LANGUAGES | |
| for code in ("en", "hi", "kn", "te", "mr", "bn", "ml"): | |
| assert code in SUPPORTED_LANGUAGES | |
| from agrosense.translation import IdentityTranslator | |
| assert IdentityTranslator().translate("hello", "en", "hi") == "hello" | |
| def test_english_bypasses_translation(): | |
| e = _engine() | |
| e._translator = _FakeTranslator() | |
| ans = e.answer("maize fertilizer", language="en") | |
| assert ans.language == "en" and ans.translation_backend is None | |
| assert not ans.text.startswith("[") # no translation marker | |
| def test_non_english_translates_query_in_and_answer_out(): | |
| e = _engine() | |
| e._translator = _FakeTranslator() | |
| ans = e.answer("maize fertilizer", language="hi") | |
| assert ans.language == "hi" and ans.translation_backend == "fake" | |
| assert ans.text.startswith("[hi] ") # answer translated to target | |
| assert ans.citations # retrieval still worked (query->en) | |
| assert "Maize" in {c.crop for c in ans.citations} | |
| class _FakeAutoTranslator: | |
| """Mimics a backend that auto-detects (deep-translator). Records call sources.""" | |
| name = "fake-auto" | |
| supports_auto = True | |
| def __init__(self): | |
| self.calls = [] | |
| def translate(self, text, source, target): | |
| self.calls.append((source, target)) | |
| if target == "en": # query-in: auto-detected English -> unchanged | |
| return text | |
| return f"[{target}] {text}" | |
| def test_query_in_uses_auto_detect_and_preserves_retrieval(): | |
| # Regression: an already-English query under a non-English answer language must | |
| # NOT be mistranslated as if it were that language (which broke retrieval). | |
| e = _engine() | |
| ft = _FakeAutoTranslator() | |
| e._translator = ft | |
| ans = e.answer("maize fertilizer", language="hi") | |
| assert ("auto", "en") in ft.calls # query translated with auto-detect | |
| assert "Maize" in {c.crop for c in ans.citations} # retrieval preserved | |
| assert ans.text.startswith("[hi] ") # answer still translated out | |
| def test_translation_failure_falls_back_to_english(): | |
| e = _engine() | |
| e._translator = _RaisingTranslator() | |
| ans = e.answer("maize fertilizer", language="hi") | |
| assert ans.translation_backend is None # failed -> not applied | |
| assert "Sources" in ans.text # English grounded answer preserved | |
| # --- market prices, mock-tested without a data.gov.in key --- | |
| _PRICES_PAYLOAD = { | |
| "records": [ | |
| {"market": "Belgaum", "commodity": "Tomato", "variety": "Local", | |
| "state": "Karnataka", "district": "Belagavi", "arrival_date": "2026-05-28", | |
| "min_price": "800", "max_price": "1400", "modal_price": "1100"}, | |
| {"market": "Hubli", "commodity": "Tomato", "variety": "Hybrid", | |
| "state": "Karnataka", "district": "Dharwad", "arrival_date": "2026-05-28", | |
| "min_price": "900", "max_price": "1600", "modal_price": "1500"}, | |
| {"market": "BadData", "commodity": "Tomato", "variety": "", | |
| "state": "Karnataka", "district": "", "arrival_date": "2026-05-28", | |
| "min_price": "NA", "max_price": None, "modal_price": "x"}, # bad -> None | |
| ] | |
| } | |
| def test_prices_parse_and_summary_grounded(): | |
| from agrosense.prices import PriceReport, parse_records | |
| rows = parse_records(_PRICES_PAYLOAD) | |
| assert len(rows) == 3 | |
| assert rows[2].modal_price is None and rows[2].min_price is None # bad row cleaned | |
| rep = PriceReport(commodity="Tomato", state="Karnataka", records=rows) | |
| s = rep.summary() | |
| assert s["modal_min"] == 1100 and s["modal_max"] == 1500 and s["count"] == 3 | |
| notes = " ".join(rep.notes()) | |
| assert "Modal price" in notes and "vary notably" in notes # spread > 20% | |
| def test_price_client_disabled_without_key(): | |
| from agrosense.prices import MarketPriceClient, get_price_client | |
| from agrosense import config | |
| assert MarketPriceClient(api_key=None).available() is False | |
| assert MarketPriceClient(api_key=None).fetch(commodity="Tomato") is None | |
| if not config.prices_configured(): | |
| assert get_price_client() is None | |
| class _FakePriceClient: | |
| def fetch(self, commodity=None, state=None, market=None, limit=None): | |
| from agrosense.prices import PriceReport, parse_records | |
| return PriceReport(commodity=commodity, state=state, | |
| records=parse_records(_PRICES_PAYLOAD)) | |
| def test_engine_attaches_prices_for_retrieved_crop(): | |
| e = _engine() | |
| e._prices = _FakePriceClient() | |
| ans = e.answer("price of tomato", include_prices=True) | |
| assert ans.prices is not None | |
| assert ans.prices["commodity"] == "Tomato" # derived from top retrieved crop | |
| assert ans.prices["summary"]["modal_max"] == 1500 | |
| def test_engine_no_prices_when_flag_off(): | |
| e = _engine() | |
| e._prices = _FakePriceClient() | |
| ans = e.answer("price of tomato", include_prices=False) | |
| assert ans.prices is None | |
| # --- decision-fusion advisories (pure fuse(), no network) --- | |
| def _mk_weather(daily_specs): | |
| from agrosense.weather import DailyWeather, WeatherForecast | |
| daily = [DailyWeather(date=f"2026-06-0{i+1}", tmax_c=t, tmin_c=tn, | |
| precip_mm=mm, precip_prob=pp) | |
| for i, (t, tn, mm, pp) in enumerate(daily_specs)] | |
| return WeatherForecast(location_name="Test", latitude=15.0, longitude=74.0, | |
| current_temp_c=28.0, current_precip_mm=0.0, | |
| current_humidity=60.0, daily=daily) | |
| def _mk_sat(total_precip=None, solar=22.0, ndvi_status=None, ndvi_trend=None): | |
| from agrosense.satellite import AgroClimate, SatelliteReport | |
| from agrosense.ndvi import NDVIResult | |
| ac = None | |
| if total_precip is not None: | |
| ac = AgroClimate(start="2026-05-01", end="2026-05-31", days=30, | |
| avg_solar_mj=solar, avg_tmax_c=34.0, avg_tmin_c=22.0, | |
| total_precip_mm=total_precip) | |
| nd = None | |
| if ndvi_status is not None: | |
| nd = NDVIResult(location_name="Test", latitude=15.0, longitude=74.0, buffer_m=100, | |
| latest=0.7, mean=0.65, trend=ndvi_trend, status=ndvi_status) | |
| return SatelliteReport(location_name="Test", latitude=15.0, longitude=74.0, | |
| truecolor_date="2026-05-26", ndvi_date="2026-05-11", | |
| imagery={"ndvi": "u"}, agroclimate=ac, numeric_ndvi=nd) | |
| def _titles(report): | |
| return {a.title for a in report.advisories} | |
| def test_fusion_rain_plus_falling_ndvi_flags_drainage_high(): | |
| from agrosense.fusion import fuse | |
| w = _mk_weather([(32, 23, 25, 90), (31, 23, 20, 85)]) # heavy rain both days | |
| sat = _mk_sat(total_precip=80, ndvi_status="moderate", ndvi_trend="falling") | |
| rep = fuse(w, sat, location_name="Test") | |
| titles = _titles(rep) | |
| assert "Drainage & disease check" in titles | |
| assert "Hold sprays & N top-dressing" in titles | |
| assert rep.advisories[0].urgency == "high" # sorted high-first | |
| assert "Good field-work window" not in titles # it's not dry | |
| def test_fusion_dry_low_rain_sparse_ndvi_flags_irrigation_and_stress(): | |
| from agrosense.fusion import fuse | |
| w = _mk_weather([(36, 24, 0.0, 5), (37, 24, 0.0, 0)]) # dry | |
| sat = _mk_sat(total_precip=3, ndvi_status="sparse/stressed", ndvi_trend="falling") | |
| rep = fuse(w, sat, location_name="Test") | |
| titles = _titles(rep) | |
| assert "Irrigate soon" in titles # dry + low recent rain | |
| assert "Likely crop stress over the field" in titles # sparse NDVI + dry | |
| assert all(a.urgency in ("high", "medium", "low") for a in rep.advisories) | |
| def test_fusion_healthy_rising_ndvi_is_low_urgency_only(): | |
| from agrosense.fusion import fuse | |
| w = _mk_weather([(30, 22, 2.0, 30), (29, 22, 3.0, 40)]) # not rainy, not dry | |
| sat = _mk_sat(total_precip=50, ndvi_status="healthy/dense", ndvi_trend="rising") | |
| rep = fuse(w, sat, location_name="Test") | |
| titles = _titles(rep) | |
| assert "Canopy healthy" in titles | |
| assert all(a.urgency != "high" for a in rep.advisories) # nothing urgent | |
| def test_fusion_no_contradictory_maintain_with_urgent_irrigation(): | |
| # A healthy, rising crop during a dry spell with low recent rain: must NOT show | |
| # "Canopy healthy / maintain" alongside a HIGH "Irrigate soon". | |
| from agrosense.fusion import fuse | |
| w = _mk_weather([(36, 24, 0.0, 5), (37, 24, 0.0, 0)]) # dry | |
| sat = _mk_sat(total_precip=3, ndvi_status="healthy/dense", ndvi_trend="rising") | |
| rep = fuse(w, sat, location_name="Test") | |
| titles = _titles(rep) | |
| assert "Irrigate soon" in titles # urgent need stands | |
| assert "Canopy healthy" not in titles # suppressed (would contradict) | |
| def test_fusion_hint_when_ndvi_not_configured(): | |
| from agrosense.fusion import fuse | |
| w = _mk_weather([(30, 22, 2.0, 30)]) | |
| sat = _mk_sat(total_precip=50, ndvi_status=None) # no numeric NDVI | |
| rep = fuse(w, sat, location_name="Test") | |
| assert "Enable field-level NDVI" in _titles(rep) | |
| def test_fusion_handles_missing_signals(): | |
| from agrosense.fusion import fuse | |
| rep = fuse(None, None, location_name="Nowhere", latitude=1.0, longitude=2.0) | |
| assert rep.advisories == [] and rep.location_name == "Nowhere" | |
| assert "No actionable signals" in rep.to_context() | |
| def test_engine_fusion_requires_location(): | |
| assert _engine().get_fusion_advisories() is None | |
| # --- location environment profile (pure parsers, no network) --- | |
| def test_compass_and_aqi_category(): | |
| from agrosense.environment import compass, aqi_category | |
| assert compass(0) == "N" and compass(90) == "E" and compass(180) == "S" | |
| assert compass(270) == "W" and compass(266) == "W" and compass(None) is None | |
| assert aqi_category(30) == "Good" and aqi_category(75) == "Moderate" | |
| assert aqi_category(180) == "Unhealthy" and aqi_category(400) == "Hazardous" | |
| assert aqi_category(None) is None | |
| def test_parse_air_quality_and_pollen_region_note(): | |
| from agrosense.environment import parse_air_quality, parse_pollen | |
| payload = {"current": {"european_aqi": 18, "us_aqi": 34, "pm2_5": 7.5, | |
| "pm10": 9.6, "ozone": 40, "grass_pollen": None}} | |
| aq = parse_air_quality(payload) | |
| assert aq.us_aqi == 34 and aq.pm2_5 == 7.5 and aq.category == "Good" | |
| # India: pollen is null -> not available, with an explanatory note. | |
| pol = parse_pollen(payload) | |
| assert pol.available is False and "Europe" in pol.note | |
| # Europe-style payload with real pollen values -> available. | |
| pol2 = parse_pollen({"current": {"grass_pollen": 12, "birch_pollen": 3}}) | |
| assert pol2.available is True and pol2.values["grass"] == 12 | |
| def test_engine_environment_requires_location(): | |
| assert _engine().get_environment() is None | |
| class _FakeEnvClient: | |
| def profile(self, place=None, latitude=None, longitude=None): | |
| from agrosense.environment import EnvironmentProfile, Sunlight, Wind | |
| return EnvironmentProfile( | |
| location_name=place or "pt", latitude=15.0, longitude=74.0, | |
| elevation_m=769.0, population=490045, humidity_pct=92.0, | |
| sunlight=Sunlight(sunshine_hours=11.5, uv_index_max=8.95), | |
| wind=Wind(speed_kmh=12.6, direction_deg=266.0, direction_compass="W")) | |
| def _find_adv(rep, prefix): | |
| return next((a for a in rep.advisories if a.title.startswith(prefix)), None) | |
| def test_crop_profiles_and_stage_normalization(): | |
| from agrosense.crop_profiles import get_crop_profile, normalize_stage | |
| assert get_crop_profile("Tomato").heat_stress_c == 32 # heat-sensitive | |
| assert get_crop_profile("Cotton").heat_stress_c == 40 # heat-tolerant | |
| assert get_crop_profile("unknown").name == "default" | |
| assert normalize_stage("Flowering") == "flowering" | |
| assert normalize_stage("bogus") is None and normalize_stage(None) is None | |
| def test_fusion_per_crop_heat_threshold(): | |
| from agrosense.fusion import fuse | |
| w = _mk_weather([(34, 24, 0.0, 0)]) # 34°C, dry | |
| sat = _mk_sat(total_precip=50) | |
| # Tomato (threshold 32) -> heat advisory; Cotton (threshold 40) -> none. | |
| assert _find_adv(fuse(w, sat, crop="tomato"), "Heat stress") is not None | |
| assert _find_adv(fuse(w, sat, crop="cotton"), "Heat stress") is None | |
| def test_fusion_stage_escalates_urgency(): | |
| from agrosense.fusion import fuse | |
| w = _mk_weather([(39, 24, 0.0, 0)]) # above maize heat threshold (38) | |
| sat = _mk_sat(total_precip=50) | |
| veg = _find_adv(fuse(w, sat, crop="maize", stage="vegetative"), "Heat stress") | |
| flo = _find_adv(fuse(w, sat, crop="maize", stage="flowering"), "Heat stress") | |
| assert veg.urgency == "medium" # not a yield-critical stage | |
| assert flo.urgency == "high" # flowering escalates | |
| def test_fusion_maturity_rain_flags_harvest_quality(): | |
| from agrosense.fusion import fuse | |
| w = _mk_weather([(30, 22, 25, 90)]) # heavy rain | |
| rep = fuse(w, _mk_sat(total_precip=50), crop="wheat", stage="maturity") | |
| assert _find_adv(rep, "Harvest-quality risk") is not None | |
| assert rep.crop == "Wheat" and rep.stage == "maturity" | |
| def test_fusion_stage_note_surfaced(): | |
| from agrosense.fusion import fuse | |
| rep = fuse(_mk_weather([(30, 22, 2.0, 30)]), _mk_sat(total_precip=50), | |
| crop="paddy", stage="flowering") | |
| note = _find_adv(rep, "Stage watch") | |
| assert note is not None and note.urgency == "high" # flowering is yield-critical | |
| # --- planetary positions (pure ephemeris, no network) --- | |
| def test_planetary_sun_declination_matches_season(): | |
| from datetime import datetime, timezone | |
| from agrosense.planetary import compute_positions | |
| # June solstice: solar declination ~ +23.4°. | |
| jun = compute_positions(15.0, 74.0, when=datetime(2026, 6, 21, 12, 0, tzinfo=timezone.utc)) | |
| sun = next(b for b in jun.bodies if b.name == "Sun") | |
| assert 22.5 < sun.dec_deg < 23.7, sun.dec_deg | |
| # March equinox: solar declination ~ 0°. | |
| mar = compute_positions(15.0, 74.0, when=datetime(2026, 3, 20, 12, 0, tzinfo=timezone.utc)) | |
| sun2 = next(b for b in mar.bodies if b.name == "Sun") | |
| assert abs(sun2.dec_deg) < 1.5, sun2.dec_deg | |
| def test_lunar_day_tithi_mapping_and_header(): | |
| from datetime import datetime, timezone | |
| from agrosense.planetary import tithi_from_elongation, lunar_day | |
| # Boundaries: 0 deg -> Shukla Pratipada (1); 180 -> Krishna Pratipada (16). | |
| assert tithi_from_elongation(0) == {"tithi": 1, "paksha": "Shukla", | |
| "name": "Pratipada", "label": "Shukla Pratipada"} | |
| assert tithi_from_elongation(179.9)["name"] == "Purnima" # full-moon tithi (15) | |
| assert tithi_from_elongation(180)["label"] == "Krishna Pratipada" # tithi 16 | |
| assert tithi_from_elongation(354)["name"] == "Amavasya" and tithi_from_elongation(354)["tithi"] == 30 | |
| # Real instant: tithi in range, label is "<Paksha> <Name>". | |
| ld = lunar_day(datetime(2026, 5, 30, 12, 0, tzinfo=timezone.utc)) | |
| assert 1 <= ld["tithi"] <= 30 and ld["paksha"] in ("Shukla", "Krishna") | |
| assert ld["label"].startswith(ld["paksha"]) | |
| # The date header carries the lunar day for the UI top bar. | |
| from agrosense.calendars import datetime_header | |
| h = datetime_header(datetime(2026, 5, 30, 12, 0, tzinfo=timezone.utc)) | |
| assert h["lunar_day"] and ("Shukla" in h["lunar_day"] or "Krishna" in h["lunar_day"]) | |
| def test_traditional_suitability_rules(): | |
| from agrosense.traditional import astrological_suitability | |
| # Favourable nakshatra + waxing moon -> Favourable for sowing. | |
| good = astrological_suitability("sowing", {"nakshatra": "Rohini", "paksha": "Shukla", | |
| "karana": "Bava", "tithi_number": 5}) | |
| assert good["verdict"] == "Favourable" and any("Rohini" in r for r in good["reasons"]) | |
| # Vishti (Bhadra) karana -> postpone new work even if nakshatra is good. | |
| vishti = astrological_suitability("sowing", {"nakshatra": "Rohini", "paksha": "Shukla", | |
| "karana": "Vishti", "tithi_number": 5}) | |
| assert vishti["verdict"] == "Better to postpone" | |
| # Amavasya -> avoid sowing. | |
| amav = astrological_suitability("sowing", {"nakshatra": "Hasta", "paksha": "Krishna", | |
| "karana": "Naga", "tithi_number": 30}) | |
| assert amav["verdict"] == "Better to postpone" and any("Amavasya" in r for r in amav["reasons"]) | |
| # Waning moon favours pest control / harvest. | |
| pest = astrological_suitability("pest_control", {"nakshatra": "Chitra", "paksha": "Krishna", | |
| "karana": "Gara", "tithi_number": 22}) | |
| assert "favourable" in pest["verdict"].lower() | |
| def test_traditional_practices_region_and_engine(): | |
| from agrosense.traditional import practices_for | |
| # Karnataka 'Akkadi' (region-specific) ranks ahead of All-India for mixed cropping. | |
| ka = practices_for("mixed_cropping", region="Karnataka") | |
| assert ka and ka[0]["region"] == "Karnataka" | |
| sow = practices_for("sowing") | |
| assert any("Beejamrit" in p["title"] or "sowing" in p["title"].lower() or | |
| "Nakshatra" in p["title"] for p in sow) | |
| # Engine assembles panchang + suitability + practices + disclaimer. | |
| out = _engine().traditional_advice(activity="sowing") | |
| assert out["activity"] == "sowing" | |
| assert set(("vaara", "tithi", "nakshatra", "yoga", "karana")) <= set(out["panchang"]) | |
| assert out["astrology"]["verdict"] and out["practices"] | |
| assert "complementary" in out["disclaimer"].lower() | |
| def test_panchang_full(): | |
| from datetime import datetime, timezone | |
| from agrosense.planetary import panchang, NAKSHATRAS, YOGAS | |
| from agrosense.calendars import datetime_header, VAARA | |
| p = panchang(datetime(2026, 5, 30, 12, 0, tzinfo=timezone.utc)) | |
| assert p["nakshatra"] in NAKSHATRAS and 1 <= p["nakshatra_number"] <= 27 | |
| assert p["yoga"] in YOGAS | |
| assert p["karana"] in (["Kimstughna", "Shakuni", "Chatushpada", "Naga", | |
| "Bava", "Balava", "Kaulava", "Taitila", "Gara", "Vanija", "Vishti"]) | |
| # Header panchang has all five limbs (vaara from the local weekday). | |
| h = datetime_header(datetime(2026, 5, 30, 12, 0, tzinfo=timezone.utc)) | |
| pg = h["panchang"] | |
| assert pg["vaara"] in VAARA | |
| assert all(k in pg for k in ("vaara", "tithi", "paksha", "nakshatra", "yoga", "karana")) | |
| def test_planetary_report_shape_and_ranges(): | |
| from datetime import datetime, timezone | |
| from agrosense.planetary import compute_positions | |
| rep = compute_positions(15.85, 74.50, when=datetime(2026, 5, 29, 18, 30, tzinfo=timezone.utc), | |
| location_name="Belagavi") | |
| names = [b.name for b in rep.bodies] | |
| assert names == ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"] | |
| for b in rep.bodies: | |
| assert -90.0 <= b.altitude_deg <= 90.0 | |
| assert 0.0 <= b.azimuth_deg < 360.0 | |
| assert b.above_horizon == (b.altitude_deg > 0) | |
| assert 0.0 <= rep.moon_phase["illumination"] <= 1.0 | |
| assert isinstance(rep.moon_phase["name"], str) | |
| assert "Planetary positions" in rep.to_context() | |
| def test_engine_planetary_requires_location(): | |
| assert _engine().get_planetary() is None | |
| # --- evaluation harness (pure metrics + smoke run) --- | |
| def test_eval_metric_functions(): | |
| from agrosense.evaluation import (parse_fact_lines, retrieval_hit, retrieval_mrr, | |
| answer_relevance, faithfulness, _percentile) | |
| ans = "**Maize** [1]\n- Fertilizer: NPK 120:60:40\n- Pest management: scout weekly\n" | |
| facts = parse_fact_lines(ans) | |
| assert facts == ["NPK 120:60:40", "scout weekly"] | |
| assert retrieval_hit(["Maize", "Wheat"], "Maize") is True | |
| assert retrieval_hit(["Wheat"], "Maize") is False | |
| assert retrieval_mrr(["Wheat", "Maize"], "Maize") == 0.5 | |
| assert retrieval_mrr(["Maize"], "Maize") == 1.0 | |
| assert answer_relevance(ans, ["fertilizer", "pest"]) == 1.0 | |
| assert answer_relevance(ans, ["disease"]) == 0.0 | |
| # grounded: both fact values appear in grounding text -> 1.0 | |
| grounding = "Fertilizer: NPK 120:60:40. Pest management: scout weekly every day." | |
| assert faithfulness(ans, grounding) == 1.0 | |
| # hallucinated: a fact value not present -> < 1.0 | |
| assert faithfulness(ans, "Fertilizer: NPK 120:60:40") == 0.5 | |
| assert _percentile([1, 2, 3, 4], 50) in (2, 3) | |
| def test_evaluate_smoke_over_engine(): | |
| from agrosense.evaluation import EvalCase, evaluate | |
| cases = [ | |
| EvalCase("fertilizer for maize", expected_crop="Maize", intents=["fertilizer"]), | |
| EvalCase("what is the capital of France", in_domain=False), | |
| ] | |
| report = evaluate(_engine(), cases) | |
| m = report["metrics"] | |
| assert m["n_in_domain"] == 1 and m["n_out_of_domain"] == 1 | |
| assert m["context_relevance"] == 1.0 # maize query retrieves Maize | |
| assert m["faithfulness"] == 1.0 # extractive is grounded | |
| assert m["ood_accuracy"] == 1.0 # off-domain -> fallback | |
| assert "all_targets_met" in report and isinstance(report["all_targets_met"], bool) | |
| # --- real ground water (CGWB/data.gov.in), pure logic + graceful gating --- | |
| _GW_PAYLOAD = {"records": [ | |
| {"station_name": "Well-A", "latitude": "15.80", "longitude": "74.50", | |
| "data_value": "8.5", "state": "Karnataka", "arrival_date": "2026-03-01"}, | |
| {"station_name": "Well-B", "latitude": "16.50", "longitude": "75.20", | |
| "data_value": "12.0", "state": "Karnataka", "arrival_date": "2026-03-01"}, | |
| {"station_name": "Bad", "latitude": "", "longitude": "", "data_value": "9"}, # dropped | |
| ]} | |
| def test_groundwater_haversine_parse_and_nearest(): | |
| from agrosense.groundwater import haversine_km, parse_stations, nearest_station | |
| # ~111 km per degree of latitude near the equator. | |
| assert 105 < haversine_km(15.0, 74.0, 16.0, 74.0) < 115 | |
| stations = parse_stations(_GW_PAYLOAD) | |
| assert len(stations) == 2 and stations[0].depth_m == 8.5 # bad row dropped | |
| s, dist = nearest_station(stations, 15.85, 74.50) | |
| assert s.name == "Well-A" and dist < 10 # closest well | |
| def test_groundwater_client_disabled_without_config(): | |
| from agrosense.groundwater import GroundwaterClient, get_groundwater_client | |
| from agrosense import config | |
| assert GroundwaterClient(resource_id=None, api_key=None).available() is False | |
| assert GroundwaterClient(resource_id=None, api_key=None).level(15.0, 74.0) is None | |
| if not config.groundwater_configured(): | |
| assert get_groundwater_client() is None | |
| class _FakeGWClient: | |
| def level(self, lat, lon): | |
| from agrosense.groundwater import GroundwaterReading | |
| return GroundwaterReading(depth_m=8.5, station_name="Well-A", distance_km=6.2, | |
| latitude=15.80, longitude=74.50, state="Karnataka", | |
| date="2026-03-01") | |
| def test_environment_uses_real_groundwater_when_available(): | |
| from agrosense.environment import EnvironmentClient | |
| client = EnvironmentClient(groundwater_client=_FakeGWClient()) | |
| reading = client.groundwater_reading(15.85, 74.50) | |
| assert reading is not None and reading.depth_m == 8.5 | |
| # And EnvironmentClient with no provider -> None (proxy path). | |
| assert EnvironmentClient(groundwater_client=None).groundwater_reading(15.0, 74.0) is None | |
| # --- calendars (Gregorian + Indian National / Saka + IST) --- | |
| def test_indian_national_calendar_anchors(): | |
| from datetime import date | |
| from agrosense.calendars import indian_national_date | |
| # Chaitra 1 of Saka 1946 falls on 21 March 2024 (a leap year). | |
| assert indian_national_date(date(2024, 3, 21)) == (1946, 1, 1) | |
| # Chaitra 1 of Saka 1945 falls on 22 March 2023 (non-leap). | |
| assert indian_national_date(date(2023, 3, 22)) == (1945, 1, 1) | |
| # Day before Chaitra 1 belongs to the previous Saka year, last month Phalguna(12). | |
| y, m, d = indian_national_date(date(2023, 3, 21)) | |
| assert y == 1944 and m == 12 | |
| # 29 May 2026 -> 8 Jyaishtha 1948. | |
| assert indian_national_date(date(2026, 5, 29)) == (1948, 3, 8) | |
| def test_ist_and_header(): | |
| from datetime import datetime, timezone | |
| from agrosense.calendars import ist_now, datetime_header | |
| noon_utc = datetime(2026, 5, 29, 12, 0, tzinfo=timezone.utc) | |
| ist = ist_now(noon_utc) | |
| assert (ist.hour, ist.minute) == (17, 30) # UTC+5:30 | |
| h = datetime_header(noon_utc) | |
| assert "2026" in h["gregorian"] and "Saka" in h["indian_national"] | |
| assert h["ist_time"] == "17:30" | |
| # --- Reuters news feed parsing (pure) --- | |
| def test_news_parse_feed_and_clean_title(): | |
| from agrosense.news import parse_feed | |
| xml = ( | |
| '<rss><channel>' | |
| '<item><title>India monsoon weakest in 11 years - Reuters</title>' | |
| '<link>http://x/1</link><pubDate>Thu, 29 May 2026</pubDate></item>' | |
| '<item><title>Wheat crop outlook - Reuters</title>' | |
| '<link>http://x/2</link></item>' | |
| '</channel></rss>' | |
| ) | |
| items = parse_feed(xml) | |
| assert len(items) == 2 | |
| assert items[0].title == "India monsoon weakest in 11 years" # " - Reuters" stripped | |
| assert items[0].link == "http://x/1" | |
| assert items[0].source == "Google News" # default (general) | |
| # Source is overridable (used for the scoped Reuters feed). | |
| assert parse_feed(xml, source="Reuters")[0].source == "Reuters" | |
| assert parse_feed(xml, limit=1) == items[:1] | |
| assert parse_feed("not xml at all") == [] # graceful | |
| def test_news_url_region_and_topic_building(): | |
| from agrosense.news import build_news_url, LOCALES, TOPICS | |
| # General top stories for India. | |
| url, src = build_news_url(None, "IN") | |
| assert "news.google.com/rss?" in url and "ceid=IN%3Aen" in url and src == "Google News" | |
| # Topic search for the US locale. | |
| url2, _ = build_news_url(TOPICS["Business"], "US") | |
| assert "/rss/search?" in url2 and "q=business" in url2 and "ceid=US%3Aen" in url2 | |
| # Reuters site filter -> source labelled Reuters. | |
| assert build_news_url("agriculture site:reuters.com", "IN")[1] == "Reuters" | |
| # Unknown region falls back to India. | |
| assert "ceid=IN%3Aen" in build_news_url(None, "ZZ")[0] | |
| assert "United States" in [v[3] for v in LOCALES.values()] | |
| # --- commodity prices (Yahoo futures + Agmarknet), pure parse + graceful gating --- | |
| def _yahoo_payload(price, prev, currency="USD"): | |
| return {"chart": {"result": [{"meta": { | |
| "regularMarketPrice": price, "chartPreviousClose": prev, "currency": currency}}]}} | |
| def test_commodity_parse_and_currency(): | |
| from agrosense.commodities import parse_yahoo_quote, currency_symbol | |
| assert currency_symbol("USD") == "$" and currency_symbol("USX") == "¢" | |
| q = parse_yahoo_quote(_yahoo_payload(4593.8, 4500.4), "Gold", "oz") | |
| assert q.price == 4593.8 and q.unit == "oz" and q.currency == "$" | |
| assert q.change_pct == round((4593.8 - 4500.4) / 4500.4 * 100, 2) | |
| # Coffee in US cents. | |
| qc = parse_yahoo_quote(_yahoo_payload(266.0, 274.0, "USX"), "Coffee", "lb") | |
| assert qc.currency == "¢" and qc.change_pct < 0 | |
| # Bad / missing data -> None. | |
| assert parse_yahoo_quote({"chart": {"result": [{"meta": {}}]}}, "X", "oz") is None | |
| assert parse_yahoo_quote({"bad": 1}, "X", "oz") is None | |
| class _FakeYahoo: | |
| def quote(self, symbol): | |
| return _yahoo_payload(100.0, 80.0) | |
| class _FakeAgmarknet: | |
| def fetch(self, commodity=None, state=None, market=None, limit=None): | |
| from agrosense.prices import MarketPrice, PriceReport | |
| return PriceReport(commodity=commodity, state=None, records=[ | |
| MarketPrice(market="m", commodity=commodity, variety="", state="", | |
| district="", arrival_date="", min_price=100, max_price=200, | |
| modal_price=150)]) | |
| def test_commodities_client_lists_all_six_with_graceful_gating(): | |
| from agrosense.commodities import CommoditiesClient | |
| # No Agmarknet key -> Arecanut/Coconut unavailable, globals priced. | |
| items = CommoditiesClient(yahoo=_FakeYahoo(), prices=None).quotes() | |
| names = [c.name for c in items] | |
| assert names == ["Gold", "Silver", "Crude Oil", "Coffee", "Arecanut", "Coconut"] | |
| assert all(c.price == 100.0 and c.change_pct == 25.0 for c in items[:4]) | |
| assert items[4].price is None and items[5].price is None # n/a without key | |
| # With an Agmarknet provider, the mandi commodities get a modal price. | |
| items2 = CommoditiesClient(yahoo=_FakeYahoo(), prices=_FakeAgmarknet()).quotes() | |
| assert items2[4].name == "Arecanut" and items2[4].price == 150.0 | |
| assert items2[4].currency == "₹" and items2[4].unit == "quintal" | |
| # --- hazards: EONET events + FIRMS fires (pure parse) --- | |
| _EONET_PAYLOAD = {"events": [ | |
| {"title": "Flood near field", "categories": [{"title": "Floods"}], | |
| "geometry": [{"date": "2026-05-01", "type": "Point", "coordinates": [74.6, 15.9]}], | |
| "link": "http://x/a"}, | |
| {"title": "Distant storm", "categories": [{"title": "Severe Storms"}], | |
| "geometry": [{"date": "2026-05-02", "type": "Point", "coordinates": [80.0, 13.0]}]}, | |
| {"title": "Polygon fire zone", "categories": [{"title": "Wildfires"}], | |
| "geometry": [{"date": "2026-05-03", "type": "Polygon", | |
| "coordinates": [[[74.55, 15.88], [74.6, 15.9], [74.5, 15.8]]]}]}, | |
| ]} | |
| def test_eonet_parse_filters_radius_and_sorts(): | |
| from agrosense.hazards import parse_eonet_events | |
| evs = parse_eonet_events(_EONET_PAYLOAD, lat=15.85, lon=74.50, radius_km=500) | |
| titles = [e.title for e in evs] | |
| assert "Distant storm" not in titles # ~700 km away, filtered out | |
| assert "Flood near field" in titles and "Polygon fire zone" in titles | |
| assert evs[0].distance_km <= evs[1].distance_km # sorted by distance ascending | |
| flood = next(e for e in evs if e.title == "Flood near field") | |
| assert flood.latitude == 15.9 and flood.longitude == 74.6 # [lon,lat] handled | |
| def test_firms_parse_csv(): | |
| from agrosense.hazards import parse_firms_csv, FirmsClient | |
| csv = ("latitude,longitude,bright_ti4,acq_date,acq_time,confidence,frp,daynight\n" | |
| "15.90,74.60,330.1,2026-05-28,1200,n,5.2,D\n" | |
| "16.50,75.00,310.0,2026-05-28,1206,h,3.1,D\n" | |
| "bad,row,only\n") | |
| fires = parse_firms_csv(csv, lat=15.85, lon=74.50) | |
| assert len(fires) == 2 # bad row skipped | |
| assert fires[0].distance_km <= fires[1].distance_km | |
| assert fires[0].frp == 5.2 and fires[0].confidence == "n" | |
| # No key -> client unavailable, fires() returns None. | |
| assert FirmsClient(map_key=None).available() is False | |
| assert FirmsClient(map_key=None).fires(15.0, 74.0) is None | |
| def test_engine_hazards_requires_location(): | |
| assert _engine().get_hazards() is None | |
| # --- vision: plant disease + identification (pure logic + heuristic) --- | |
| def test_vision_softmax_topk_and_health(): | |
| from agrosense.vision import softmax, top_k, health_from_colors | |
| p = softmax([2.0, 1.0, 0.0]) | |
| assert abs(sum(p) - 1.0) < 1e-6 and p[0] > p[1] > p[2] | |
| tk = top_k([0.1, 0.7, 0.2], ["a", "b", "c"], k=2) | |
| assert tk[0]["label"] == "b" and len(tk) == 2 | |
| assert "Healthy" in health_from_colors({"green": 0.8, "yellow": 0.05, "brown": 0.05})[0] | |
| assert "stress" in health_from_colors({"green": 0.3, "yellow": 0.3, "brown": 0.2})[0].lower() | |
| assert "Inconclusive" in health_from_colors({"green": 0.3, "other": 0.7})[0] | |
| def _png_bytes(color): | |
| import io | |
| from PIL import Image | |
| buf = io.BytesIO() | |
| Image.new("RGB", (16, 16), color).save(buf, format="PNG") | |
| return buf.getvalue() | |
| def test_vision_heuristic_disease_and_plant_fallback(): | |
| from agrosense.vision import PlantVision | |
| pv = PlantVision(disease_model=None, plant_model=None) # no trained model | |
| green = pv.predict_disease(_png_bytes((20, 180, 40))) | |
| assert green.backend == "heuristic" and "Healthy" in green.label | |
| brown = pv.predict_disease(_png_bytes((150, 90, 40))) | |
| assert "stress" in brown.label.lower() or "Inconclusive" in brown.label | |
| plant = pv.predict_plant(_png_bytes((20, 180, 40))) | |
| assert plant.label == "Unknown" and "trained model" in plant.note | |
| pest = pv.predict_pest(_png_bytes((20, 180, 40))) | |
| assert pest.task == "pest" and pest.label == "Unknown" | |
| assert "IP102" in pest.note and pest.backend == "heuristic" | |
| class _StubPestModel: | |
| def predict(self, image_bytes): | |
| from agrosense.vision import Prediction | |
| return Prediction("pest", "Brown planthopper", 0.91, | |
| top_k=[{"label": "Brown planthopper", "prob": 0.91}], | |
| backend="keras:pest.keras", note="Trained CNN inference.") | |
| def test_vision_pest_uses_trained_model_when_present(): | |
| from agrosense.vision import PlantVision | |
| pv = PlantVision(pest_model=_StubPestModel()) | |
| p = pv.predict_pest(_png_bytes((90, 70, 40))) | |
| assert p.label == "Brown planthopper" and p.backend.startswith("keras") | |
| def test_vision_factory_without_models(): | |
| from agrosense.vision import get_plant_vision, PlantVision | |
| pv = get_plant_vision() | |
| assert isinstance(pv, PlantVision) | |
| # No model env configured -> all three backends None (heuristic path). | |
| assert pv._disease is None and pv._plant is None and pv._pest is None | |
| # --- plant telemedicine (pure logic + engine consult) --- | |
| def test_telemedicine_intents_severity_health(): | |
| from agrosense.telemedicine import (symptom_intents, assess_severity, derive_health) | |
| assert "disease" in symptom_intents("yellow spots and blight on leaves") | |
| assert "pest" in symptom_intents("holes chewed by a caterpillar") | |
| assert "nutrition" in symptom_intents("pale stunted plants") | |
| assert symptom_intents("") == [] | |
| assert assess_severity("spots spreading rapidly everywhere", 0.3) == "high" # 2+ words | |
| assert assess_severity("a few spots", 0.4) == "low" | |
| assert assess_severity(None, 0.9) == "high" # high conf | |
| assert derive_health("Healthy foliage", 0.8, False) == "Likely healthy" | |
| assert derive_health("Suspected disease", 0.4, True) == "Needs attention" | |
| def test_telemedicine_build_prescription_is_grounded_and_cited(): | |
| from agrosense.telemedicine import build_prescription | |
| meta = {"recommended_fertilizer": "NPK 120:80:60", "disease_prevention": "Spray Mancozeb 0.25%", | |
| "pest_management": "Yellow sticky traps", "source": "ICAR Bulletin"} | |
| items = build_prescription(meta, ["disease", "pest"]) | |
| cats = [i.category for i in items] | |
| assert "Treatment (disease)" in cats and "Treatment (pest)" in cats | |
| assert "Cultural / IPM" in cats and "Monitoring" in cats | |
| # Dosage text comes verbatim from the KB field, and every item is cited. | |
| assert any("Mancozeb 0.25%" in i.instruction for i in items) | |
| assert all(i.source == "ICAR Bulletin" for i in items) | |
| # No intents -> defaults to disease + nutrition. | |
| assert "Nutrition / fertilizer" in [i.category for i in build_prescription(meta, [])] | |
| def test_ipm_label_parse_and_lookup(): | |
| from agrosense.ipm import parse_vision_label, lookup_ipm | |
| # PlantVillage-style class label -> (crop, condition). | |
| assert parse_vision_label("Tomato___Late_blight") == ("Tomato", "Late blight") | |
| assert parse_vision_label("aphid") == (None, "aphid") | |
| # Class label drives the IPM entry. | |
| e = lookup_ipm("Tomato___Late_blight") | |
| assert e and e["condition"] == "Late blight" and "Mancozeb" in e["treatment"] | |
| # Symptom text naming a pest also maps. | |
| assert lookup_ipm("lots of fall armyworm in the whorl")["condition"] == "Fall armyworm" | |
| # Most specific (longest) key wins; nonsense -> None. | |
| assert lookup_ipm("powdery mildew on leaves")["condition"] == "Powdery mildew" | |
| assert lookup_ipm("the weather is nice today") is None | |
| def test_consultation_is_diagnosis_driven_with_ipm(): | |
| # Symptom names a specific disease -> targeted IPM prescription + updated diagnosis. | |
| ans = _engine().plant_consultation(crop="Tomato", | |
| symptoms="late blight lesions spreading on leaves") | |
| assert "Late blight" in ans["diagnosis"] | |
| cats = [p["category"] for p in ans["prescription"]] | |
| assert any(c.startswith("Treatment (diagnosed: Late blight)") for c in cats) | |
| # The targeted IPM treatment text + its source are present and cited. | |
| rx = " ".join(p["instruction"] for p in ans["prescription"]) | |
| assert "Mancozeb" in rx or "Chlorothalonil" in rx | |
| assert any("IPM Guide" in c for c in ans["citations"]) | |
| def test_engine_plant_consultation_grounded(): | |
| ans = _engine().plant_consultation(crop="Tomato", | |
| symptoms="yellow spots spreading on the lower leaves") | |
| assert ans["crop"] == "Tomato" and ans["diagnosis_basis"] == "symptoms" | |
| assert ans["health_status"] == "Needs attention" | |
| assert ans["severity"] in ("moderate", "high") | |
| assert len(ans["prescription"]) >= 3 and ans["citations"] | |
| assert "disclaimer" in ans and "extension officer" in ans["disclaimer"] | |
| assert ans["weather_note"] is None # no location given | |
| # --- farmers' digital clubs --- | |
| def test_club_validate_and_store(): | |
| import tempfile | |
| from pathlib import Path | |
| from agrosense.clubs import validate_club, ClubStore | |
| assert validate_club("X", "location", "Karnataka") is None | |
| assert "name" in validate_club("", "location", "KA") | |
| assert "location" in validate_club("X", "bogus", "KA").lower() | |
| assert "commodity" in validate_club("X", "commodity", "").lower() | |
| with tempfile.TemporaryDirectory() as d: | |
| store = ClubStore(path=Path(d) / "clubs.json", video_base="https://v.example") | |
| # seeded with defaults on first use | |
| assert len(store.all()) >= 4 | |
| assert any(c.type == "location" for c in store.all()) | |
| # create + room url + creator auto-joined | |
| c = store.create("Mango Growers", "commodity", "Mango", "desc", creator="Asha") | |
| assert c.id == "mango-growers" and "Asha" in c.members | |
| assert store.public(c)["room_url"] == "https://v.example/AgroSense-Club-mango-growers" | |
| # join + post | |
| store.join(c.id, "Ravi") | |
| store.add_post(c.id, "Ravi", "First mango harvest done!", link="http://x") | |
| got = store.get(c.id) | |
| assert "Ravi" in got.members and got.posts[-1]["text"].startswith("First mango") | |
| # filter by type/key/search | |
| assert all(x.type == "commodity" for x in store.filter(ctype="commodity")) | |
| assert any(x.key == "Mango" for x in store.filter(key="mango")) | |
| assert any("Mango" in x.name for x in store.filter(search="mango")) | |
| def test_engine_clubs_flow(): | |
| from agrosense.config import DATA_DIR | |
| from pathlib import Path | |
| p = Path(DATA_DIR) / "clubs.json" | |
| original = p.read_text(encoding="utf-8") if p.exists() else None | |
| try: | |
| e = _engine() | |
| loc = e.list_clubs(ctype="location") | |
| assert loc and all(c["type"] == "location" for c in loc) | |
| club = e.create_club("Test Banana Club", "commodity", "Banana", "d", creator="Meena") | |
| assert club["member_count"] == 1 and "Banana" in club["key"] | |
| joined = e.join_club(club["id"], "Karthik") | |
| assert joined["member_count"] == 2 | |
| posted = e.post_to_club(club["id"], "Karthik", "Panama wilt spreading — advice?") | |
| assert posted["posts"][-1]["author"] == "Karthik" | |
| assert e.get_club(club["id"])["room_url"].endswith("AgroSense-Club-" + club["id"]) | |
| finally: | |
| if original is None: | |
| p.unlink(missing_ok=True) | |
| else: | |
| p.write_text(original, encoding="utf-8") | |
| def test_trading_validate_and_store(): | |
| import tempfile | |
| from pathlib import Path | |
| from agrosense.trading import validate_listing, TradingStore | |
| assert validate_listing("sell", "Tomato", 500, 18) is None | |
| assert "Type" in validate_listing("lease", "Tomato", 1, 1) | |
| assert "Commodity" in validate_listing("sell", "", 1, 1) | |
| assert "Quantity" in validate_listing("sell", "Tomato", 0, 1) | |
| assert "Quantity" in validate_listing("sell", "Tomato", "abc", 1) | |
| assert "Price" in validate_listing("sell", "Tomato", 1, -5) | |
| with tempfile.TemporaryDirectory() as d: | |
| store = TradingStore(path=Path(d) / "m.json", video_base="https://v.example") | |
| assert len(store.all()) >= 3 # seeded with demos | |
| assert any(l.type == "buy" for l in store.all()) and any(l.type == "sell" for l in store.all()) | |
| l = store.create({"type": "sell", "commodity": "Mango", "quantity": "20", | |
| "unit": "quintal", "price": "4500", "location": "Ratnagiri", | |
| "state": "Maharashtra", "seller": "Asha"}) | |
| assert l.id == "mango-sell" and l.quantity == 20.0 and l.price == 4500.0 | |
| assert store.public(l)["room_url"] == "https://v.example/AgroSense-Deal-mango-sell" | |
| # only open listings by default; closed hidden unless include_closed | |
| store.set_status(l.id, "closed") | |
| assert all(x.id != "mango-sell" for x in store.filter()) | |
| assert any(x.id == "mango-sell" for x in store.filter(include_closed=True)) | |
| # inquiry blocked on a closed listing | |
| try: | |
| store.add_inquiry(l.id, "Buyer", "999", "interested") | |
| assert False, "expected closed-listing error" | |
| except ValueError: | |
| pass | |
| # reopen + inquire + filters | |
| store.set_status(l.id, "open") | |
| got = store.add_inquiry(l.id, "Buyer", "99999", "Can do 4400?", quantity="10") | |
| assert got.inquiries[-1]["contact"] == "99999" and got.inquiries[-1]["quantity"] == 10.0 | |
| assert all(x.type == "sell" for x in store.filter(ltype="sell")) | |
| assert any(x.commodity == "Mango" for x in store.filter(commodity="mang")) | |
| assert any(x.id == "mango-sell" for x in store.filter(state="maharashtra")) | |
| # empty inquiry rejected | |
| try: | |
| store.add_inquiry(l.id, "X", "", "") | |
| assert False, "expected empty-inquiry error" | |
| except ValueError: | |
| pass | |
| def test_engine_trading_flow(): | |
| from agrosense.config import DATA_DIR | |
| from pathlib import Path | |
| p = Path(DATA_DIR) / "market_listings.json" | |
| original = p.read_text(encoding="utf-8") if p.exists() else None | |
| try: | |
| e = _engine() | |
| sells = e.list_listings(ltype="sell") | |
| assert sells and all(l["type"] == "sell" for l in sells) | |
| created = e.create_listing({"type": "sell", "commodity": "Banana", "quantity": 12, | |
| "unit": "dozen", "price": 60, "location": "Theni", | |
| "state": "Tamil Nadu", "seller": "Murugan"}) | |
| assert created["commodity"] == "Banana" and created["status"] == "open" | |
| assert created["room_url"].endswith("AgroSense-Deal-" + created["id"]) | |
| inq = e.inquire_listing(created["id"], "Wholesaler", "98765", "Bulk order?", quantity=100) | |
| assert inq["inquiry_count"] == 1 | |
| closed = e.close_listing(created["id"]) | |
| assert closed["status"] == "closed" | |
| # closed listing drops out of the default browse | |
| assert all(l["id"] != created["id"] for l in e.list_listings()) | |
| # bad input -> ValueError | |
| try: | |
| e.create_listing({"type": "sell", "commodity": "", "quantity": 1, "price": 1}) | |
| assert False, "expected ValueError" | |
| except ValueError: | |
| pass | |
| finally: | |
| if original is None: | |
| p.unlink(missing_ok=True) | |
| else: | |
| p.write_text(original, encoding="utf-8") | |
| # --- live agri-doctor consultation --- | |
| def _experts(): | |
| from agrosense.consultation import ExpertDirectory | |
| return ExpertDirectory.load().experts | |
| def test_consultation_expert_matching_and_room_url(): | |
| from agrosense.consultation import match_expert, room_url | |
| experts = _experts() | |
| # Disease -> pathology expert; pest -> entomology; honor language when possible. | |
| assert "pathology" in match_expert(experts, "disease").tags | |
| assert "entomology" in match_expert(experts, "pest").tags | |
| kn = match_expert(experts, "disease", "Kannada") | |
| assert kn is not None and "Kannada" in kn.languages | |
| # Unknown area falls back to an available expert (general/any), never None here. | |
| assert match_expert(experts, "zzz") is not None | |
| assert room_url("https://meet.jit.si/", "C0007") == "https://meet.jit.si/AgroSense-Consult-C0007" | |
| def test_consultation_service_lifecycle(): | |
| from agrosense.consultation import ConsultationService, ExpertDirectory | |
| svc = ConsultationService(ExpertDirectory.load(), video_base="https://v.example") | |
| req = svc.create("Asha", "Tomato", "AI: late blight", channel="video", area="disease") | |
| assert req.id == "C0001" and req.status == "assigned" and req.expert is not None | |
| assert req.room_url == "https://v.example/AgroSense-Consult-C0001" | |
| assert req.messages and req.messages[0].sender == "system" | |
| # Chat append + retrieval. | |
| svc.add_message(req.id, "farmer", "Leaves have brown lesions") | |
| got = svc.get(req.id) | |
| assert got.messages[-1].text == "Leaves have brown lesions" | |
| assert svc.get("nope") is None | |
| def test_engine_request_live_consult_attaches_ai_context(): | |
| e = _engine() | |
| out = e.request_live_consult(farmer_name="Ravi", crop="Maize", | |
| symptoms="fall armyworm larvae in the whorl", | |
| channel="video", language="Hindi") | |
| assert out["status"] == "assigned" and out["expert"] is not None | |
| assert out["room_url"].endswith("AgroSense-Consult-" + out["id"]) | |
| assert "ai_consult" in out and "Fall armyworm" in out["ai_consult"]["diagnosis"] | |
| # Pest symptom should route to the entomology expert. | |
| assert "entomology" in out["expert"]["tags"] | |
| # Chat round-trip through the engine. | |
| updated = e.add_consult_message(out["id"], "farmer", "Spreading fast") | |
| assert updated["messages"][-1]["text"] == "Spreading fast" | |
| # --- expert notifications --- | |
| _REQ = {"id": "C0001", "farmer_name": "Asha", "crop": "Tomato", | |
| "summary": "AI: Late blight", "channel": "video", | |
| "room_url": "https://meet.jit.si/AgroSense-Consult-C0001", | |
| "expert": {"name": "Dr. Anjali Rao", "specialization": "Plant Pathology"}} | |
| def test_notification_message_and_log(): | |
| from agrosense.notifications import build_consult_notification, LogNotifier | |
| subject, body = build_consult_notification(_REQ) | |
| assert "C0001" in subject and "Tomato" in subject | |
| assert "Dr. Anjali Rao" in body and "meet.jit.si" in body | |
| log = LogNotifier() | |
| n = log.send(subject, body, {}) | |
| assert n.ok and n.channel == "log" and log.sent == [n] | |
| def test_webhook_notifier_gating_and_post(): | |
| from agrosense.notifications import WebhookNotifier | |
| assert WebhookNotifier(url=None).available() is False | |
| assert WebhookNotifier(url=None).send("s", "b", {}) is None | |
| captured = {} | |
| def fake_post(url, json_payload, timeout): | |
| captured["url"] = url | |
| captured["payload"] = json_payload | |
| wh = WebhookNotifier(url="https://hook.example/x", poster=fake_post) | |
| n = wh.send("subj", "body", {"consultation_id": "C0001"}) | |
| assert n.ok and captured["url"] == "https://hook.example/x" | |
| assert captured["payload"]["consultation_id"] == "C0001" and captured["payload"]["subject"] == "subj" | |
| def boom(url, json_payload, timeout): | |
| raise RuntimeError("down") | |
| assert WebhookNotifier(url="https://x", poster=boom).send("s", "b", {}).ok is False | |
| def test_notifier_default_is_log_only_and_history(): | |
| from agrosense.notifications import Notifier | |
| nf = Notifier() # no webhook/email env -> log backend only | |
| out = nf.notify_consult(_REQ) | |
| assert [n.channel for n in out] == ["log"] | |
| assert len(nf.history()) == 1 | |
| def test_notifier_with_injected_webhook(): | |
| from agrosense.notifications import Notifier, LogNotifier, WebhookNotifier | |
| posted = [] | |
| wh = WebhookNotifier(url="https://hook/x", poster=lambda u, p, t: posted.append(p)) | |
| nf = Notifier(backends=[LogNotifier(), wh]) | |
| out = nf.notify_consult(_REQ) | |
| assert {n.channel for n in out} == {"log", "webhook"} and posted | |
| def test_engine_consult_records_notification(): | |
| e = _engine() | |
| out = e.request_live_consult(crop="Tomato", symptoms="late blight on leaves") | |
| assert out.get("notifications") and out["notifications"][0]["channel"] == "log" | |
| # System message records that the expert was notified. | |
| assert any("notified" in m["text"].lower() for m in out["messages"]) | |
| assert e.get_notifications() # audit history is non-empty | |
| # --- internet radio (Radio Browser) --- | |
| def test_radio_parse_stations(): | |
| from agrosense.radio import parse_stations | |
| data = [ | |
| {"name": "AIR Dharwad", "url": "http://x/1", "url_resolved": "https://x/1r", | |
| "codec": "AAC", "bitrate": 64, "state": "Karnataka", "votes": 10}, | |
| {"name": "Radio Indigo 91.9 FM", "url": "https://x/2", "codec": "MP3", | |
| "bitrate": 128, "state": "Karnataka"}, | |
| {"name": "AIR Dharwad", "url": "https://x/dup"}, # duplicate name -> dropped | |
| {"name": "No URL station", "url": "", "url_resolved": ""}, # no url -> dropped | |
| {"name": "", "url": "https://x/3"}, # no name -> dropped | |
| ] | |
| out = parse_stations(data, limit=10) | |
| assert [s["name"] for s in out] == ["AIR Dharwad", "Radio Indigo 91.9 FM"] | |
| assert out[0]["url"] == "https://x/1r" # prefers url_resolved | |
| assert out[1]["codec"] == "MP3" and out[1]["bitrate"] == 128 | |
| assert parse_stations(data, limit=1)[0]["name"] == "AIR Dharwad" # limit honoured | |
| assert parse_stations([]) == [] | |
| # --- plant-doctor onboarding & verification --- | |
| def test_doctor_validate_and_tags(): | |
| from agrosense.doctors import validate_application, derive_tags | |
| ok = {"name": "Dr X", "specialization": "Plant Pathology", "region": "KVK", | |
| "contact": "x@y.com", "languages": "English, Hindi", "credentials": "PhD", | |
| "registration_no": "ICAR-12345"} | |
| assert validate_application(ok) is None | |
| assert "name" in validate_application({**ok, "name": ""}) | |
| assert "language" in validate_application({**ok, "languages": ""}) | |
| assert "Credentials" in validate_application({**ok, "credentials": ""}) | |
| assert "registration" in validate_application({**ok, "registration_no": ""}).lower() | |
| assert "format" in validate_application({**ok, "registration_no": "x@"}).lower() | |
| assert "pathology" in derive_tags("Plant Pathology") and "agronomy" in derive_tags("x") | |
| assert "entomology" in derive_tags("Entomology / pests") | |
| def test_doctor_ratings_summary_and_range(): | |
| import json, tempfile | |
| from pathlib import Path | |
| from agrosense.doctors import DoctorRegistry | |
| with tempfile.TemporaryDirectory() as d: | |
| seed = Path(d) / "seed.json" | |
| seed.write_text(json.dumps([{"id": "v1", "name": "Doc V", "specialization": "Agronomy", | |
| "tags": ["agronomy"], "languages": ["English"], "region": "KVK", "available": True}]), | |
| encoding="utf-8") | |
| reg = DoctorRegistry(path=Path(d) / "docs.json", seed_path=seed) | |
| assert reg.get("v1").rating_summary() == (None, 0) | |
| reg.add_rating("v1", 5, "excellent") | |
| reg.add_rating("v1", 4) | |
| avg, count = reg.get("v1").rating_summary() | |
| assert avg == 4.5 and count == 2 | |
| prof = reg.get("v1").public_profile() | |
| assert prof["rating_avg"] == 4.5 and prof["rating_count"] == 2 | |
| assert "contact" in prof and prof["ratings"][0]["comment"] == "excellent" | |
| for bad in (0, 6, "x"): | |
| try: | |
| reg.add_rating("v1", bad); assert False | |
| except ValueError: | |
| pass | |
| try: | |
| reg.add_rating("nope", 5); assert False | |
| except KeyError: | |
| pass | |
| def test_doctor_registry_onboard_and_verify(): | |
| import json, tempfile | |
| from pathlib import Path | |
| from agrosense.doctors import DoctorRegistry | |
| with tempfile.TemporaryDirectory() as d: | |
| seed = Path(d) / "seed.json" | |
| seed.write_text(json.dumps([{"id": "e1", "name": "Seed Doc", | |
| "specialization": "Agronomy", "tags": ["agronomy"], "languages": ["English"], | |
| "region": "KVK", "available": True}]), encoding="utf-8") | |
| reg = DoctorRegistry(path=Path(d) / "docs.json", seed_path=seed) | |
| assert len(reg.verified()) == 1 # seed pre-verified | |
| doc = reg.onboard({"name": "Dr Asha", "specialization": "Entomology", | |
| "region": "Pune", "contact": "a@b.com", | |
| "languages": "English, Hindi", "credentials": "PhD Ento", | |
| "registration_no": "REG-1234"}) | |
| assert doc.status == "pending" and "entomology" in doc.tags | |
| assert len(reg.verified()) == 1 # pending not routable yet | |
| reg.set_status(doc.id, "verified") | |
| assert len(reg.verified()) == 2 and reg.get(doc.id).status == "verified" | |
| reg.set_status(doc.id, "rejected", "incomplete") | |
| assert len(reg.verified()) == 1 | |
| try: | |
| reg.onboard({"name": "No Creds", "specialization": "x", "region": "y", | |
| "contact": "z", "languages": "English"}); assert False | |
| except ValueError: | |
| pass | |
| assert reg.get("nope") is None | |
| def test_engine_doctor_onboard_then_verify_enters_directory(): | |
| from agrosense.config import DATA_DIR | |
| from pathlib import Path | |
| p = Path(DATA_DIR) / "plant_doctors.json" | |
| original = p.read_text(encoding="utf-8") if p.exists() else None | |
| try: | |
| e = _engine() | |
| before = len(e.list_experts()) | |
| doc = e.onboard_doctor({"name": "Dr Neem", "specialization": "Entomology", | |
| "region": "Nagpur", "contact": "n@e.com", | |
| "languages": "English, Marathi", "credentials": "PhD", | |
| "registration_no": "ICAR-7788"}) | |
| assert doc["status"] == "pending" | |
| # pending -> appears in admin listing but NOT in the verified consult directory | |
| assert any(x["id"] == doc["id"] for x in e.list_doctors("pending")) | |
| assert all(x["id"] != doc["id"] for x in e.list_experts()) | |
| # cannot rate / view an unverified doctor | |
| assert e.get_doctor_profile(doc["id"]) is None | |
| # verify -> now in the directory (routable) with a public profile | |
| e.verify_doctor(doc["id"], approve=True) | |
| assert any(x["id"] == doc["id"] for x in e.list_experts()) | |
| assert len(e.list_experts()) == before + 1 | |
| # rate the now-verified doctor and see it reflected in the profile + directory | |
| prof = e.rate_doctor(doc["id"], 5, "very helpful") | |
| assert prof["rating_avg"] == 5.0 and prof["rating_count"] == 1 | |
| listed = next(x for x in e.list_experts() if x["id"] == doc["id"]) | |
| assert listed["rating_avg"] == 5.0 and listed["registration_no"] == "ICAR-7788" | |
| finally: | |
| if original is None: | |
| p.unlink(missing_ok=True) | |
| else: | |
| p.write_text(original, encoding="utf-8") | |
| # --- knowledge-base admin (CRUD + live reload) --- | |
| def test_kb_validate_entry(): | |
| from agrosense.kb_admin import validate_entry | |
| assert validate_entry({"crop": "Paddy", "source": "ICAR"}) is None | |
| assert "crop" in validate_entry({"source": "x"}) | |
| assert "source" in validate_entry({"crop": "x"}) | |
| assert "rainfall_mm" in validate_entry({"crop": "x", "source": "y", "rainfall_mm": "lots"}) | |
| def test_kbstore_crud_on_tempfile(): | |
| import json | |
| import tempfile | |
| from pathlib import Path | |
| from agrosense.kb_admin import KBStore | |
| with tempfile.TemporaryDirectory() as d: | |
| p = Path(d) / "kb.json" | |
| p.write_text("[]", encoding="utf-8") | |
| store = KBStore(p) | |
| e = store.add({"crop": "Quinoa", "soil_type": "Sandy", "source": "Test", | |
| "rainfall_mm": "500"}) | |
| assert e["id"] == "quinoa-001" and e["rainfall_mm"] == 500 # id + coercion | |
| assert len(store.entries()) == 1 | |
| store.update("quinoa-001", {"crop": "Quinoa", "source": "Test2", "soil_type": "Loamy"}) | |
| assert store.entries()[0]["soil_type"] == "Loamy" | |
| assert store.entries()[0]["source"] == "Test2" | |
| try: | |
| store.add({"crop": ""}) | |
| assert False, "expected ValueError" | |
| except ValueError: | |
| pass | |
| assert store.delete("quinoa-001") is True and store.entries() == [] | |
| assert store.delete("nope") is False | |
| assert json.loads(p.read_text(encoding="utf-8")) == [] # persisted | |
| def test_engine_kb_admin_add_retrieve_delete_with_reload(): | |
| from agrosense.config import KB_PATH | |
| from pathlib import Path | |
| original = Path(KB_PATH).read_text(encoding="utf-8") | |
| try: | |
| e = _engine() | |
| before = e.num_documents | |
| added = e.add_kb_entry({"crop": "Dragonfruit", "soil_type": "Sandy loam", | |
| "recommended_fertilizer": "NPK 60:60:60", | |
| "source": "Admin test"}) | |
| assert e.num_documents == before + 1 # index rebuilt | |
| ans = e.answer("dragonfruit fertilizer") | |
| assert "Dragonfruit" in {c.crop for c in ans.citations} # now retrievable | |
| e.delete_kb_entry(added["id"]) | |
| assert e.num_documents == before | |
| finally: | |
| Path(KB_PATH).write_text(original, encoding="utf-8") | |
| def test_engine_environment_profile_serializes_all_fields(): | |
| e = _engine() | |
| e._environment = _FakeEnvClient() | |
| d = e.get_environment(location="Belagavi").to_dict() | |
| for key in ("latitude", "longitude", "elevation_m", "population", "humidity_pct", | |
| "sunlight", "wind", "air_quality", "pollen", "groundwater"): | |
| assert key in d | |
| assert d["elevation_m"] == 769.0 and d["wind"]["direction_compass"] == "W" | |
| def test_subsidies_filter_get_and_updates(): | |
| from agrosense import subsidies | |
| subsidies._CACHE = None | |
| all_schemes = subsidies.list_schemes() | |
| assert len(all_schemes) >= 10 | |
| central = subsidies.list_schemes(level="central") | |
| state = subsidies.list_schemes(level="state") | |
| assert central and state and all(s["level"] == "central" for s in central) | |
| assert len(central) + len(state) == len(all_schemes) | |
| # state filter keeps central (apply everywhere) + the matching state's schemes | |
| ka = subsidies.list_schemes(state="Karnataka") | |
| assert any(s["level"] == "central" for s in ka) | |
| assert any(s["state"] == "Karnataka" for s in ka) | |
| assert not any(s["level"] == "state" and s["state"] != "Karnataka" for s in ka) | |
| # search matches name/summary/category | |
| found = subsidies.list_schemes(search="insurance") | |
| assert found and all("insurance" in (s["name"] + s["summary"] + s["category"]).lower() | |
| for s in found) | |
| # full detail | |
| detail = subsidies.get_scheme("pm-kisan") | |
| assert detail and detail["application_process"] and detail["documents"] and detail["portal"] | |
| assert subsidies.get_scheme("does-not-exist") is None | |
| # recent_updates aggregates across schemes, newest first, capped | |
| ups = subsidies.recent_updates(limit=5) | |
| assert len(ups) <= 5 and all("scheme" in u and "date" in u for u in ups) | |
| assert ups == sorted(ups, key=lambda u: u["date"], reverse=True) | |
| def test_subsidies_add_update_persists_on_tempfile(): | |
| import json | |
| import tempfile | |
| from pathlib import Path | |
| from agrosense import subsidies | |
| data = [{"id": "demo", "name": "Demo Scheme", "level": "central", "state": "", | |
| "category": "Test", "summary": "x", "updates": []}] | |
| with tempfile.TemporaryDirectory() as d: | |
| p = Path(d) / "subsidies.json" | |
| p.write_text(json.dumps(data), encoding="utf-8") | |
| s = subsidies.add_update("demo", "2026-05-30", "New guidelines issued.", path=p) | |
| assert s["updates"][-1]["text"] == "New guidelines issued." | |
| on_disk = json.loads(p.read_text(encoding="utf-8")) | |
| assert on_disk[0]["updates"][-1]["date"] == "2026-05-30" # persisted | |
| try: | |
| subsidies.add_update("nope", "2026-05-30", "x", path=p) | |
| assert False, "expected KeyError" | |
| except KeyError: | |
| pass | |
| subsidies._CACHE = None # add_update invalidated cache; ensure real data reloads | |
| def test_engine_subsidies_flow(): | |
| e = _engine() | |
| schemes = e.list_subsidies(level="central") | |
| assert schemes and all(s["level"] == "central" for s in schemes) | |
| one = e.get_subsidy(schemes[0]["id"]) | |
| assert one and "application_process" in one | |
| assert isinstance(e.subsidy_updates(limit=3), list) | |
| def test_engine_subsidies_resolves_city_to_state(): | |
| # The UI sends the sidebar Location (a CITY, e.g. "Belagavi") as `state`. | |
| # The engine must geocode it to admin1 ("Karnataka") so state schemes show. | |
| import agrosense.weather as weather | |
| e = _engine() | |
| orig = weather.geocode | |
| weather.geocode = lambda q: {"admin1": "Karnataka"} if "belagavi" in q.lower() else None | |
| try: | |
| ka = e.list_subsidies(state="Belagavi") # city input | |
| assert any(s["level"] == "state" and s["state"] == "Karnataka" for s in ka), \ | |
| "city location must surface its state's schemes" | |
| assert not any(s["level"] == "state" and s["state"] != "Karnataka" for s in ka) | |
| # a state name passed directly still works (no geocode needed) | |
| ka2 = e.list_subsidies(state="Karnataka") | |
| assert any(s["state"] == "Karnataka" for s in ka2) | |
| # unresolved place -> graceful fallback: show all (don't hide state schemes) | |
| allp = e.list_subsidies(state="Nowhereville") | |
| states = {s["state"] for s in allp if s["level"] == "state"} | |
| assert len(states) >= 3 | |
| finally: | |
| weather.geocode = orig | |
| def test_finance_catalog_filter_and_get(): | |
| from agrosense import finance | |
| finance._CACHE = None | |
| products = finance.list_products() | |
| assert len(products) >= 10 | |
| cats = finance.categories() | |
| assert cats and "Crop loan / working capital" in cats | |
| # category + search filters | |
| crop = finance.list_products(category="Crop loan") | |
| assert crop and all("crop loan" in p["category"].lower() for p in crop) | |
| found = finance.list_products(search="tractor") | |
| assert found and any("Machinery" in p["name"] or "machinery" in p["summary"].lower() | |
| for p in found) | |
| # full detail | |
| kcc = finance.get_product("kcc-credit") | |
| assert kcc and kcc["application_process"] and kcc["documents"] and kcc["interest"] | |
| assert finance.get_product("nope") is None | |
| def test_finance_apply_validation_and_persist(): | |
| import json | |
| from pathlib import Path | |
| from agrosense import finance | |
| assert finance.validate_application("", "x") and "name" in finance.validate_application("", "x").lower() | |
| assert "contact" in finance.validate_application("Ravi", "").lower() | |
| p = Path(finance.APPLICATIONS_PATH) | |
| original = p.read_text(encoding="utf-8") if p.exists() else None | |
| try: | |
| # unknown product -> KeyError | |
| try: | |
| finance.apply("nope", "Ravi", "999") | |
| assert False, "expected KeyError" | |
| except KeyError: | |
| pass | |
| # missing contact -> ValueError | |
| try: | |
| finance.apply("kcc-credit", "Ravi", "") | |
| assert False, "expected ValueError" | |
| except ValueError: | |
| pass | |
| app = finance.apply("kcc-credit", "Ravi", "98765", amount="50000", | |
| location="Belagavi", message="crop loan", at="10:00") | |
| assert app["id"] == "FA0001" and app["product"] and app["amount"] == 50000.0 | |
| app2 = finance.apply("agri-term-loan", "Asha", "asha@x.in") | |
| assert app2["id"] == "FA0002" and app2["amount"] is None | |
| apps = finance.list_applications() | |
| assert apps[0]["id"] == "FA0002" # newest first | |
| on_disk = json.loads(p.read_text(encoding="utf-8")) | |
| assert len(on_disk) == 2 and on_disk[0]["name"] == "Ravi" # persisted, insertion order | |
| finally: | |
| if original is None: | |
| p.unlink(missing_ok=True) | |
| else: | |
| p.write_text(original, encoding="utf-8") | |
| def test_engine_finance_flow(): | |
| from pathlib import Path | |
| from agrosense import finance | |
| e = _engine() | |
| crop = e.list_finance(category="Crop loan") | |
| assert crop and all("crop loan" in p["category"].lower() for p in crop) | |
| assert "Farm mechanization" in e.finance_categories() | |
| one = e.get_finance(crop[0]["id"]) | |
| assert one and "application_process" in one | |
| p = Path(finance.APPLICATIONS_PATH) | |
| original = p.read_text(encoding="utf-8") if p.exists() else None | |
| try: | |
| app = e.apply_finance("agri-gold-loan", "Murugan", "90000", amount=20000) | |
| assert app["product_id"] == "agri-gold-loan" and app["status"] == "received" | |
| assert any(a["id"] == app["id"] for a in e.list_finance_applications()) | |
| try: | |
| e.apply_finance("agri-gold-loan", "X", "") # missing contact | |
| assert False, "expected ValueError" | |
| except ValueError: | |
| pass | |
| finally: | |
| if original is None: | |
| p.unlink(missing_ok=True) | |
| else: | |
| p.write_text(original, encoding="utf-8") | |
| def test_land_records_directory_and_fallback(): | |
| from agrosense import land_records | |
| land_records._CACHE = None | |
| states = land_records.list_states() | |
| assert "Karnataka" in states and "Maharashtra" in states and "_default" not in states | |
| ka = land_records.get_for_state("karnataka") # case-insensitive | |
| assert ka["system"] == "Bhoomi" and "Pahani" in ka["record_name"] | |
| assert ka["portal"].startswith("http") and ka["steps"] and ka["search_by"] | |
| mh = land_records.get_for_state("Maharashtra") | |
| assert "7/12" in mh["record_name"] | |
| # unknown state -> generic _default with the requested name + covered flag | |
| fallback = land_records.get_for_state("Atlantis") | |
| assert fallback["state"] == "Atlantis" and fallback.get("covered") is False | |
| assert fallback["portal"].startswith("http") # still points somewhere useful | |
| # no state at all -> default without covered flag set False on a real name | |
| none = land_records.get_for_state(None) | |
| assert none["steps"] and "covered" not in none | |
| def test_land_records_guide_bytes(): | |
| from agrosense import land_records | |
| entry = land_records.get_for_state("Karnataka") | |
| try: | |
| pdf = land_records.build_guide(entry, "pdf") | |
| assert pdf[:4] == b"%PDF" and len(pdf) > 500 | |
| docx = land_records.build_guide(entry, "docx") | |
| assert docx[:2] == b"PK" and len(docx) > 500 # docx is a zip | |
| except ImportError: | |
| print(" (skipped guide-bytes: python-docx/fpdf2 not installed)") | |
| try: | |
| land_records.build_guide(entry, "rtf") | |
| assert False, "expected ValueError for bad format" | |
| except ValueError: | |
| pass | |
| def test_engine_land_record_resolves_city_and_builds_guide(): | |
| import agrosense.weather as weather | |
| e = _engine() | |
| orig = weather.geocode | |
| weather.geocode = lambda q: {"admin1": "Karnataka"} if "belagavi" in q.lower() else None | |
| try: | |
| info = e.land_record_info(location="Belagavi") # city -> Karnataka | |
| assert info["system"] == "Bhoomi" and "disclaimer" in info | |
| assert "Karnataka" in info["covered_states"] | |
| info2 = e.land_record_info(state="Maharashtra") # explicit state | |
| assert "7/12" in info2["record_name"] | |
| # unresolved city -> generic guide, not a crash | |
| info3 = e.land_record_info(location="Nowhereville") | |
| assert info3["steps"] | |
| try: | |
| fname, data = e.land_record_guide(state="Karnataka", fmt="pdf") | |
| assert fname.endswith(".pdf") and "Karnataka" in fname and data[:4] == b"%PDF" | |
| except ImportError: | |
| print(" (skipped guide build: doc deps not installed)") | |
| finally: | |
| weather.geocode = orig | |
| if __name__ == "__main__": | |
| failures = 0 | |
| for name, fn in sorted(globals().items()): | |
| if name.startswith("test_") and callable(fn): | |
| try: | |
| fn() | |
| print(f"PASS {name}") | |
| except AssertionError as exc: | |
| failures += 1 | |
| print(f"FAIL {name}: {exc}") | |
| print(f"\n{'OK' if not failures else f'{failures} FAILED'}") | |
| raise SystemExit(1 if failures else 0) | |