import uuid from datetime import date from unittest.mock import Mock, patch from django.test import TestCase from farm_data.models import PlantCatalogSnapshot, SensorData from farm_data.services import assign_farm_plants_from_backend_ids from irrigation.models import IrrigationMethod from location_data.models import SoilLocation from rag.services.fertilization import get_fertilization_recommendation from rag.services.irrigation import get_irrigation_recommendation from weather.models import WeatherForecast class RecommendationServiceDefaultsTests(TestCase): def setUp(self): self.location = SoilLocation.objects.create( latitude="35.700000", longitude="51.400000", farm_boundary={"type": "Polygon", "coordinates": []}, ) WeatherForecast.objects.create( location=self.location, forecast_date=date(2026, 4, 10), temperature_min=12.0, temperature_max=23.0, temperature_mean=18.0, ) self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=101, name="گوجه‌فرنگی") self.onion = PlantCatalogSnapshot.objects.create(backend_plant_id=102, name="پیاز") self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای") self.farm_uuid = uuid.uuid4() self.farm = SensorData.objects.create( farm_uuid=self.farm_uuid, center_location=self.location, irrigation_method=self.irrigation_method, sensor_payload={ "sensor-7-1": { "soil_moisture": 30.0, "nitrogen": 18.0, "phosphorus": 12.0, "potassium": 14.0, "soil_ph": 6.9, } }, ) assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id]) def build_irrigation_optimizer_result(self): return { "engine": "crop_simulation_heuristic", "context_text": "optimizer irrigation context", "recommended_strategy": { "code": "balanced", "label": "آبیاری متعادل", "score": 88.0, "expected_yield_index": 91.0, "total_irrigation_mm": 24.0, "amount_per_event_mm": 8.0, "events": 3, "frequency_per_week": 3, "event_dates": ["2026-04-10"], "timing": "اوایل صبح", "moisture_target_percent": 70, "validity_period": "معتبر برای 3 روز آینده", "reasoning": ["شبیه ساز این سناریو را برتر ارزیابی کرد."], }, "alternatives": [ { "code": "protective", "label": "آبیاری حمایتی", "score": 80.0, "expected_yield_index": 85.0, "total_irrigation_mm": 28.0, } ], } def build_irrigation_llm_result(self): return ( '{"plan": {"frequencyPerWeek": 3, "durationMinutes": 42, "bestTimeOfDay": "اوایل صبح", ' '"moistureLevel": 68, "warning": "بررسی شود"}, ' '"timeline": [{"step_number": 1, "title": "بازبینی", "description": "لاین ها بررسی شوند"}], ' '"sections": [{"type": "warning", "title": "هشدار", "icon": "alert-triangle", "content": "بررسی شود"}, ' '{"type": "tip", "title": "نکته", "icon": "bulb", "content": "مورد سفارشی"}]}' ) def build_fertilization_optimizer_result(self): return { "engine": "crop_simulation_heuristic", "context_text": "optimizer fertilization context", "recommended_strategy": { "code": "balanced", "label": "تغذیه متعادل", "score": 84.0, "expected_yield_index": 88.0, "fertilizer_type": "20-20-20", "amount_kg_per_ha": 65.0, "application_method": "کودآبیاری", "timing": "صبح زود", "validity_period": "معتبر برای 7 روز آینده", "reasoning": ["کسری عناصر با این سناریو بهتر پوشش داده می شود."], }, "alternatives": [ { "code": "maintenance", "label": "تغذیه نگهدارنده", "score": 72.0, "expected_yield_index": 78.0, "fertilizer_type": "20-20-20", "amount_kg_per_ha": 45.0, "application_method": "کودآبیاری", "timing": "صبح زود", "reasoning": ["برای نگهداری تعادل تغذیه ای گزینه سبک تری است."], } ], } @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) @patch("rag.services.irrigation.resolve_kc", return_value=0.9) @patch("rag.services.irrigation.resolve_crop_profile", return_value={}) @patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text") @patch("rag.services.irrigation.build_plant_text", return_value="plant text") @patch("rag.services.irrigation.build_rag_context", return_value="") @patch("rag.services.irrigation._get_optimizer") @patch("rag.services.irrigation.get_chat_client") def test_irrigation_recommendation_uses_farm_relations_when_request_omits_names( self, mock_get_chat_client, mock_get_optimizer, mock_build_rag_context, mock_build_plant_text, mock_build_irrigation_method_text, _mock_resolve_crop_profile, _mock_resolve_kc, _mock_calculate_forecast_water_needs, ): mock_get_optimizer.return_value.optimize_irrigation.return_value = ( self.build_irrigation_optimizer_result() ) mock_response = Mock() mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))] mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response result = get_irrigation_recommendation( farm_uuid=str(self.farm_uuid), growth_stage="میوه‌دهی", ) self.assertEqual(result["plan"]["frequencyPerWeek"], 3) self.assertEqual(result["plan"]["bestTimeOfDay"], "اوایل صبح") mock_build_rag_context.assert_called_once() mock_build_plant_text.assert_called_once_with("گوجه‌فرنگی", "میوه‌دهی") mock_build_irrigation_method_text.assert_called_once_with("آبیاری قطره‌ای") self.assertEqual( result["selected_irrigation_method"]["name"], "آبیاری قطره‌ای", ) self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic") self.assertEqual(result["timeline"][0]["title"], "بازبینی") self.assertEqual(result["sections"][1]["type"], "tip") self.assertEqual(result["water_balance"]["active_kc"], 0.9) @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) @patch("rag.services.irrigation.resolve_kc", return_value=0.9) @patch("rag.services.irrigation.resolve_crop_profile", return_value={}) @patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text") @patch("rag.services.irrigation.build_plant_text", return_value="plant text") @patch("rag.services.irrigation.build_rag_context", return_value="") @patch("rag.services.irrigation._get_optimizer") @patch("rag.services.irrigation.get_chat_client") def test_irrigation_recommendation_reads_from_canonical_farm_data_assignments( self, mock_get_chat_client, mock_get_optimizer, _mock_build_rag_context, mock_build_plant_text, _mock_build_irrigation_method_text, _mock_resolve_crop_profile, _mock_resolve_kc, _mock_calculate_forecast_water_needs, ): assign_farm_plants_from_backend_ids(self.farm, [self.onion.backend_plant_id, self.plant.backend_plant_id]) mock_get_optimizer.return_value.optimize_irrigation.return_value = self.build_irrigation_optimizer_result() mock_response = Mock() mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))] mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response result = get_irrigation_recommendation( farm_uuid=str(self.farm_uuid), growth_stage="میوه‌دهی", ) self.assertEqual(result["selected_plant"]["name"], "پیاز") mock_build_plant_text.assert_called_once_with("پیاز", "میوه‌دهی") @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) @patch("rag.services.irrigation.resolve_kc", return_value=0.9) @patch("rag.services.irrigation.resolve_crop_profile", return_value={}) @patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text") @patch("rag.services.irrigation.build_plant_text", return_value="plant text") @patch("rag.services.irrigation.build_rag_context", return_value="") @patch("rag.services.irrigation._get_optimizer") @patch("rag.services.irrigation.get_chat_client") def test_irrigation_recommendation_persists_selected_method_on_farm( self, mock_get_chat_client, mock_get_optimizer, _mock_build_rag_context, _mock_build_plant_text, mock_build_irrigation_method_text, _mock_resolve_crop_profile, _mock_resolve_kc, _mock_calculate_forecast_water_needs, ): sprinkler = IrrigationMethod.objects.create(name="بارانی") self.farm.irrigation_method = None self.farm.save(update_fields=["irrigation_method", "updated_at"]) mock_get_optimizer.return_value.optimize_irrigation.return_value = ( self.build_irrigation_optimizer_result() ) mock_response = Mock() mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))] mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response result = get_irrigation_recommendation( farm_uuid=str(self.farm_uuid), growth_stage="میوه‌دهی", irrigation_method_name="بارانی", ) self.farm.refresh_from_db() self.assertEqual(self.farm.irrigation_method_id, sprinkler.id) self.assertEqual(result["selected_irrigation_method"]["id"], sprinkler.id) mock_build_irrigation_method_text.assert_called_once_with("بارانی") self.assertEqual(result["plan"]["warning"], "بررسی شود") @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) @patch("rag.services.irrigation.resolve_kc", return_value=0.9) @patch("rag.services.irrigation.resolve_crop_profile", return_value={}) @patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text") @patch("rag.services.irrigation.build_plant_text", return_value="plant text") @patch("rag.services.irrigation.build_rag_context", return_value="") @patch("rag.services.irrigation._get_optimizer") @patch("rag.services.irrigation.get_chat_client") def test_irrigation_recommendation_falls_back_to_optimizer_when_llm_returns_invalid_payload( self, mock_get_chat_client, mock_get_optimizer, _mock_build_rag_context, _mock_build_plant_text, _mock_build_irrigation_method_text, _mock_resolve_crop_profile, _mock_resolve_kc, _mock_calculate_forecast_water_needs, ): mock_get_optimizer.return_value.optimize_irrigation.return_value = ( self.build_irrigation_optimizer_result() ) mock_response = Mock() mock_response.choices = [Mock(message=Mock(content="not-json"))] mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response result = get_irrigation_recommendation( farm_uuid=str(self.farm_uuid), growth_stage="میوه‌دهی", ) self.assertEqual(result["plan"]["frequencyPerWeek"], 3) self.assertEqual(result["timeline"][0]["step_number"], 1) self.assertEqual(result["sections"][0]["type"], "warning") self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic") @patch("rag.services.fertilization.build_plant_text", return_value="plant text") @patch("rag.services.fertilization.build_rag_context", return_value="") @patch("rag.services.fertilization._get_optimizer") @patch("rag.services.fertilization.get_chat_client") def test_fertilization_recommendation_uses_farm_plant_when_request_omits_name( self, mock_get_chat_client, mock_get_optimizer, _mock_build_rag_context, mock_build_plant_text, ): mock_get_optimizer.return_value.optimize_fertilization.return_value = ( self.build_fertilization_optimizer_result() ) mock_response = Mock() mock_response.choices = [Mock(message=Mock(content='{"status": "success", "data": {"primary_recommendation": {"display_title": "کود کامل 20-20-20", "reasoning": "توضیح", "summary": "مصرف انجام شود"}, "nutrient_analysis": {"macro": [{"key": "n", "name": "نیتروژن (N)", "value": 20, "unit": "percent", "description": "..."}, {"key": "p", "name": "فسفر (P)", "value": 20, "unit": "percent", "description": "..."}, {"key": "k", "name": "پتاسیم (K)", "value": 20, "unit": "percent", "description": "..."}], "micro": []}, "application_guide": {"safety_warning": "از اختلاط نامناسب خودداری شود.", "steps": [{"step_number": 1, "title": "آماده سازی", "description": "مورد 1"}]}, "alternative_recommendations": []}}'))] mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response result = get_fertilization_recommendation( farm_uuid=str(self.farm_uuid), growth_stage="رویشی", ) self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0) mock_build_plant_text.assert_called_once_with("گوجه‌فرنگی", "رویشی") self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic") self.assertEqual(result["data"]["application_guide"]["safety_warning"], "از اختلاط نامناسب خودداری شود.") @patch("rag.services.fertilization.build_plant_text", return_value="plant text") @patch("rag.services.fertilization.build_rag_context", return_value="") @patch("rag.services.fertilization._get_optimizer") @patch("rag.services.fertilization.get_chat_client") def test_fertilization_recommendation_resolves_requested_plant_from_catalog( self, mock_get_chat_client, mock_get_optimizer, _mock_build_rag_context, mock_build_plant_text, ): mock_get_optimizer.return_value.optimize_fertilization.return_value = ( self.build_fertilization_optimizer_result() ) mock_response = Mock() mock_response.choices = [Mock(message=Mock(content="not-json"))] mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response result = get_fertilization_recommendation( farm_uuid=str(self.farm_uuid), plant_name="پیاز", growth_stage="گلدهی", ) optimizer_call = mock_get_optimizer.return_value.optimize_fertilization.call_args.kwargs self.assertEqual(getattr(optimizer_call["plant"], "name", None), "پیاز") self.assertEqual(optimizer_call["growth_stage"], "flowering") mock_build_plant_text.assert_called_once_with("پیاز", "flowering") self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") @patch("rag.services.fertilization.build_plant_text", return_value="plant text") @patch("rag.services.fertilization.build_rag_context", return_value="") @patch("rag.services.fertilization._get_optimizer") @patch("rag.services.fertilization.get_chat_client") def test_fertilization_recommendation_uses_canonical_assignment_lookup_for_requested_catalog_plant( self, mock_get_chat_client, mock_get_optimizer, _mock_build_rag_context, mock_build_plant_text, ): assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id, self.onion.backend_plant_id]) mock_get_optimizer.return_value.optimize_fertilization.return_value = self.build_fertilization_optimizer_result() mock_response = Mock() mock_response.choices = [Mock(message=Mock(content="not-json"))] mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response result = get_fertilization_recommendation( farm_uuid=str(self.farm_uuid), plant_name="پیاز", growth_stage="گلدهی", ) optimizer_call = mock_get_optimizer.return_value.optimize_fertilization.call_args.kwargs self.assertEqual(getattr(optimizer_call["plant"], "name", None), "پیاز") mock_build_plant_text.assert_called_once_with("پیاز", "flowering") self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") @patch("rag.services.fertilization.build_plant_text", return_value="plant text") @patch("rag.services.fertilization.build_rag_context", return_value="") @patch("rag.services.fertilization._get_optimizer") @patch("rag.services.fertilization.get_chat_client") def test_fertilization_recommendation_falls_back_to_optimizer_when_llm_returns_invalid_payload( self, mock_get_chat_client, mock_get_optimizer, _mock_build_rag_context, _mock_build_plant_text, ): mock_get_optimizer.return_value.optimize_fertilization.return_value = ( self.build_fertilization_optimizer_result() ) mock_response = Mock() mock_response.choices = [Mock(message=Mock(content="not-json"))] mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response result = get_fertilization_recommendation( farm_uuid=str(self.farm_uuid), growth_stage="رویشی", ) self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0) class RecommendationCanonicalSnapshotTests(TestCase): @patch("rag.services.irrigation.get_chat_client") @patch("rag.services.irrigation.build_rag_context", return_value="") @patch("rag.services.irrigation.build_ai_farm_snapshot") def test_irrigation_ui_payload_uses_aggregated_snapshot_metrics(self, mock_snapshot, _mock_context, mock_client): from rag.services.irrigation import _build_irrigation_ui_payload mock_snapshot.return_value = {"farm_metrics": {"resolved_metrics": {"soil_moisture": 44.0}}} payload = _build_irrigation_ui_payload( llm_result={"plan": {}, "timeline": [], "sections": []}, optimizer_result=None, daily_water_needs=[], crop_profile={}, active_kc=0.9, irrigation_method=None, ai_snapshot=mock_snapshot.return_value, ) self.assertEqual(payload["plan"]["moistureLevel"], 44) @patch("rag.services.fertilization._get_optimizer") @patch("rag.services.fertilization.get_chat_client") @patch("rag.services.fertilization.build_rag_context", return_value="") @patch("rag.services.fertilization.build_ai_farm_snapshot") def test_fertilization_recommendation_includes_snapshot_provenance(self, mock_snapshot, _mock_context, mock_client, mock_optimizer): from rag.services.fertilization import get_fertilization_recommendation client = mock_client.return_value client.chat.completions.create.return_value = type("Resp", (), {"choices": [type("Choice", (), {"message": type("Msg", (), {"content": '{"status": "success", "data": {}}'})()})()]})() mock_snapshot.return_value = { "source_metadata": { "farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"}, } } mock_optimizer.return_value.optimize_fertilization.return_value = None with patch("rag.services.fertilization.SensorData.objects.select_related") as mock_select: mock_select.return_value.prefetch_related.return_value.filter.return_value.first.return_value = None result = get_fertilization_recommendation(farm_uuid="farm-1") self.assertEqual(result["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot") self.assertEqual(result["source_metadata"]["weather"]["policy"], "center_location_latest_forecast")