"""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 " ". 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 = ( '' 'India monsoon weakest in 11 years - Reuters' 'http://x/1Thu, 29 May 2026' 'Wheat crop outlook - Reuters' 'http://x/2' '' ) 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)