agrosense / tests /test_rag.py
johnpitteera's picture
Upload folder using huggingface_hub
d27b187 verified
Raw
History Blame Contribute Delete
79.3 kB
"""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)