256 lines
10 KiB
Python
256 lines
10 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from datetime import timedelta
|
||
|
|
from types import SimpleNamespace
|
||
|
|
from unittest.mock import MagicMock, patch
|
||
|
|
|
||
|
|
from django.test import TestCase, override_settings
|
||
|
|
from django.utils import timezone
|
||
|
|
from rest_framework.test import APIClient
|
||
|
|
|
||
|
|
from soile.services import SoilMoistureHeatmapService
|
||
|
|
|
||
|
|
|
||
|
|
@override_settings(ROOT_URLCONF="soile.urls")
|
||
|
|
class SoilMoistureHeatmapApiTests(TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.client = APIClient()
|
||
|
|
|
||
|
|
@patch("soile.views.apps.get_app_config")
|
||
|
|
def test_heatmap_api_returns_payload(self, mock_get_app_config):
|
||
|
|
mock_service = SimpleNamespace(
|
||
|
|
get_heatmap=lambda **_kwargs: {
|
||
|
|
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"location": {"lat": 35.7, "lon": 51.4},
|
||
|
|
"current_sensor": {"soil_moisture": 22.5},
|
||
|
|
"soil_profile": [{"depth_label": "0-5cm", "field_capacity": 0.34}],
|
||
|
|
"timestamp": "2026-04-01T00:00:00",
|
||
|
|
"grid_resolution": {"lat_step": 0.001, "lon_step": 0.001, "rows": 2, "cols": 2},
|
||
|
|
"grid_cells": [{"lat": 35.7, "lon": 51.4, "moisture_value": 22.5, "quality_flag": "REAL"}],
|
||
|
|
"sensor_points": [{"sensor_id": "farm-1", "soil_moisture_value": 22.5}],
|
||
|
|
"quality_legend": {"REAL": "اندازه گیری واقعی سنسور"},
|
||
|
|
}
|
||
|
|
)
|
||
|
|
mock_get_app_config.return_value = SimpleNamespace(
|
||
|
|
get_soil_moisture_service=lambda: mock_service
|
||
|
|
)
|
||
|
|
|
||
|
|
response = self.client.post(
|
||
|
|
"/moisture-heatmap/",
|
||
|
|
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
|
||
|
|
format="json",
|
||
|
|
)
|
||
|
|
|
||
|
|
self.assertEqual(response.status_code, 200)
|
||
|
|
payload = response.json()["data"]
|
||
|
|
self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000")
|
||
|
|
self.assertEqual(payload["current_sensor"]["soil_moisture"], 22.5)
|
||
|
|
|
||
|
|
@patch("soile.views.apps.get_app_config")
|
||
|
|
def test_heatmap_api_returns_404_for_missing_farm(self, mock_get_app_config):
|
||
|
|
mock_service = SimpleNamespace(
|
||
|
|
get_heatmap=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found."))
|
||
|
|
)
|
||
|
|
mock_get_app_config.return_value = SimpleNamespace(
|
||
|
|
get_soil_moisture_service=lambda: mock_service
|
||
|
|
)
|
||
|
|
|
||
|
|
response = self.client.post(
|
||
|
|
"/moisture-heatmap/",
|
||
|
|
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
|
||
|
|
format="json",
|
||
|
|
)
|
||
|
|
|
||
|
|
self.assertEqual(response.status_code, 404)
|
||
|
|
self.assertEqual(response.json()["msg"], "Farm not found.")
|
||
|
|
|
||
|
|
|
||
|
|
@override_settings(ROOT_URLCONF="soile.urls")
|
||
|
|
class SoilAnomalyDetectionApiTests(TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.client = APIClient()
|
||
|
|
|
||
|
|
@patch("soile.views.apps.get_app_config")
|
||
|
|
def test_anomaly_api_returns_payload(self, mock_get_app_config):
|
||
|
|
mock_service = SimpleNamespace(
|
||
|
|
get_anomaly_detection=lambda **_kwargs: {
|
||
|
|
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"generated_at": "2026-04-01T00:00:00",
|
||
|
|
"anomalies": [
|
||
|
|
{
|
||
|
|
"metric_type": "soil_moisture",
|
||
|
|
"label": "رطوبت خاک",
|
||
|
|
"severity": "high",
|
||
|
|
"observed_value": 21.4,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"interpretation": {
|
||
|
|
"summary": "ناهنجاري در رطوبت خاک شناسايي شد.",
|
||
|
|
"explanation": "رطوبت خاک از الگوي معمول فاصله گرفته است.",
|
||
|
|
"likely_cause": "احتمال اختلال در آبياري يا افزايش تبخير.",
|
||
|
|
"recommended_action": "آبياري و قرائت سنسور بازبيني شود.",
|
||
|
|
"monitoring_priority": "urgent",
|
||
|
|
"confidence": 0.84,
|
||
|
|
},
|
||
|
|
"raw_response": "{\"summary\":\"ok\"}",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
mock_get_app_config.return_value = SimpleNamespace(
|
||
|
|
get_soil_anomaly_service=lambda: mock_service
|
||
|
|
)
|
||
|
|
|
||
|
|
response = self.client.post(
|
||
|
|
"/anomaly-detection/",
|
||
|
|
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
|
||
|
|
format="json",
|
||
|
|
)
|
||
|
|
|
||
|
|
self.assertEqual(response.status_code, 200)
|
||
|
|
payload = response.json()["data"]
|
||
|
|
self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000")
|
||
|
|
self.assertEqual(payload["interpretation"]["monitoring_priority"], "urgent")
|
||
|
|
|
||
|
|
@patch("soile.views.apps.get_app_config")
|
||
|
|
def test_anomaly_api_returns_404_for_missing_farm(self, mock_get_app_config):
|
||
|
|
mock_service = SimpleNamespace(
|
||
|
|
get_anomaly_detection=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found."))
|
||
|
|
)
|
||
|
|
mock_get_app_config.return_value = SimpleNamespace(
|
||
|
|
get_soil_anomaly_service=lambda: mock_service
|
||
|
|
)
|
||
|
|
|
||
|
|
response = self.client.post(
|
||
|
|
"/anomaly-detection/",
|
||
|
|
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
|
||
|
|
format="json",
|
||
|
|
)
|
||
|
|
|
||
|
|
self.assertEqual(response.status_code, 404)
|
||
|
|
self.assertEqual(response.json()["msg"], "Farm not found.")
|
||
|
|
|
||
|
|
|
||
|
|
class SoilMoistureHeatmapServiceTests(TestCase):
|
||
|
|
@patch("soile.services.SensorData.objects")
|
||
|
|
def test_heatmap_service_builds_boundary_aware_weighted_output(self, mock_objects):
|
||
|
|
now = timezone.now()
|
||
|
|
boundary = {
|
||
|
|
"type": "Polygon",
|
||
|
|
"coordinates": [
|
||
|
|
[
|
||
|
|
[51.39, 35.70],
|
||
|
|
[51.41, 35.70],
|
||
|
|
[51.41, 35.72],
|
||
|
|
[51.39, 35.72],
|
||
|
|
[51.39, 35.70],
|
||
|
|
]
|
||
|
|
],
|
||
|
|
}
|
||
|
|
depth = SimpleNamespace(
|
||
|
|
depth_label="0-5cm",
|
||
|
|
wv0033=0.34,
|
||
|
|
wv1500=0.14,
|
||
|
|
wv0010=0.40,
|
||
|
|
nitrogen=12.0,
|
||
|
|
phh2o=7.1,
|
||
|
|
sand=40.0,
|
||
|
|
silt=35.0,
|
||
|
|
clay=25.0,
|
||
|
|
)
|
||
|
|
plants = SimpleNamespace(values_list=lambda *args, **kwargs: [1])
|
||
|
|
center_a = SimpleNamespace(latitude=35.70, longitude=51.39, farm_boundary=boundary, depths=SimpleNamespace(all=lambda: [depth]))
|
||
|
|
center_b = SimpleNamespace(latitude=35.72, longitude=51.41, farm_boundary=boundary, depths=SimpleNamespace(all=lambda: [depth]))
|
||
|
|
sensor_a = SimpleNamespace(
|
||
|
|
farm_uuid="farm-a",
|
||
|
|
center_location=center_a,
|
||
|
|
plants=plants,
|
||
|
|
sensor_payload={"sensor-1": {"soil_moisture": 20.0, "timestamp": (now - timedelta(hours=2)).isoformat()}},
|
||
|
|
updated_at=now - timedelta(hours=2),
|
||
|
|
get_sensor_block=lambda: {"soil_moisture": 20.0, "timestamp": (now - timedelta(hours=2)).isoformat()},
|
||
|
|
soil_moisture=20.0,
|
||
|
|
soil_temperature=18.0,
|
||
|
|
soil_ph=7.0,
|
||
|
|
electrical_conductivity=1.2,
|
||
|
|
nitrogen=10.0,
|
||
|
|
phosphorus=8.0,
|
||
|
|
potassium=12.0,
|
||
|
|
)
|
||
|
|
sensor_b = SimpleNamespace(
|
||
|
|
farm_uuid="farm-b",
|
||
|
|
center_location=center_b,
|
||
|
|
plants=plants,
|
||
|
|
sensor_payload={"sensor-1": {"soil_moisture": 36.0, "timestamp": (now - timedelta(hours=30)).isoformat()}},
|
||
|
|
updated_at=now - timedelta(hours=30),
|
||
|
|
get_sensor_block=lambda: {"soil_moisture": 36.0, "timestamp": (now - timedelta(hours=30)).isoformat()},
|
||
|
|
soil_moisture=36.0,
|
||
|
|
soil_temperature=19.0,
|
||
|
|
soil_ph=7.2,
|
||
|
|
electrical_conductivity=1.3,
|
||
|
|
nitrogen=11.0,
|
||
|
|
phosphorus=8.5,
|
||
|
|
potassium=12.5,
|
||
|
|
)
|
||
|
|
|
||
|
|
current_first = MagicMock()
|
||
|
|
current_first.first.return_value = sensor_a
|
||
|
|
current_filter = MagicMock()
|
||
|
|
current_filter.filter.return_value = current_first
|
||
|
|
current_qs = MagicMock()
|
||
|
|
current_qs.prefetch_related.return_value = current_filter
|
||
|
|
|
||
|
|
network_distinct = MagicMock()
|
||
|
|
network_distinct.distinct.return_value = [sensor_a, sensor_b]
|
||
|
|
network_filter = MagicMock()
|
||
|
|
network_filter.filter.return_value = network_distinct
|
||
|
|
network_qs = MagicMock()
|
||
|
|
network_qs.prefetch_related.return_value = network_filter
|
||
|
|
|
||
|
|
mock_objects.select_related.side_effect = [current_qs, network_qs]
|
||
|
|
|
||
|
|
payload = SoilMoistureHeatmapService().get_heatmap(farm_uuid="farm-a")
|
||
|
|
|
||
|
|
self.assertEqual(payload["model_metadata"]["interpolation_model"], "boundary_aware_weighted_idw")
|
||
|
|
self.assertTrue(payload["model_metadata"]["uses_freshness_weighting"])
|
||
|
|
self.assertTrue(payload["model_metadata"]["uses_boundary_mask"])
|
||
|
|
self.assertEqual(payload["summary"]["active_sensor_count"], 2)
|
||
|
|
self.assertEqual(payload["depth_layers"][0]["depth_label"], "0-5cm")
|
||
|
|
self.assertGreater(payload["sensor_points"][0]["reliability_score"], payload["sensor_points"][1]["reliability_score"])
|
||
|
|
outside_cells = [cell for cell in payload["grid_cells"] if not cell["inside_farm_boundary"]]
|
||
|
|
self.assertTrue(outside_cells)
|
||
|
|
self.assertTrue(all(cell["moisture_value"] is None for cell in outside_cells))
|
||
|
|
self.assertIn("uncertainty", payload["grid_cells"][0])
|
||
|
|
|
||
|
|
|
||
|
|
@override_settings(ROOT_URLCONF="soile.urls")
|
||
|
|
class SoilHealthSummaryApiTests(TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.client = APIClient()
|
||
|
|
|
||
|
|
@patch("soile.views.apps.get_app_config")
|
||
|
|
def test_health_summary_api_returns_payload(self, mock_get_app_config):
|
||
|
|
mock_service = SimpleNamespace(
|
||
|
|
get_health_summary=lambda **_kwargs: {
|
||
|
|
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||
|
|
"healthScore": 82,
|
||
|
|
"profileSource": "گوجه فرنگی",
|
||
|
|
"healthScoreDetails": {"components": []},
|
||
|
|
"healthLanguage": {"short_chip_text": "پایدار"},
|
||
|
|
"avgSoilMoisture": 46,
|
||
|
|
"avgSoilMoistureRaw": 45.8,
|
||
|
|
"avgSoilMoistureStatus": "بهینه",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
mock_get_app_config.return_value = SimpleNamespace(
|
||
|
|
get_soil_health_service=lambda: mock_service
|
||
|
|
)
|
||
|
|
|
||
|
|
response = self.client.post(
|
||
|
|
"/health-summary/",
|
||
|
|
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
|
||
|
|
format="json",
|
||
|
|
)
|
||
|
|
|
||
|
|
self.assertEqual(response.status_code, 200)
|
||
|
|
payload = response.json()["data"]
|
||
|
|
self.assertEqual(payload["healthScore"], 82)
|
||
|
|
self.assertEqual(payload["avgSoilMoistureStatus"], "بهینه")
|