372 lines
13 KiB
Python
372 lines
13 KiB
Python
"""
|
||
Tests for claude_service.py
|
||
|
||
Tests the Claude AI analysis with fallback to scoring service.
|
||
"""
|
||
import pytest
|
||
from unittest.mock import AsyncMock, patch, MagicMock
|
||
import os
|
||
|
||
from services.claude_service import (
|
||
analyze_case,
|
||
analyze_with_fallback,
|
||
AnalysisResult,
|
||
PrimaryZone
|
||
)
|
||
|
||
|
||
class TestAnalysisResult:
|
||
"""Test AnalysisResult model."""
|
||
|
||
def test_analysis_result_structure(self):
|
||
"""Test AnalysisResult has correct structure."""
|
||
result = AnalysisResult(
|
||
urgency="высокая",
|
||
primary_zones=[
|
||
PrimaryZone(
|
||
priority=1,
|
||
name="Зона А",
|
||
direction="N",
|
||
distance=1.0,
|
||
reason="Тест"
|
||
)
|
||
],
|
||
search_radius_km=5.0,
|
||
key_locations=["водоёмы"],
|
||
behavioral_prediction="Тест",
|
||
immediate_actions=["Действие 1"],
|
||
summary="Тест",
|
||
fallback_used=False
|
||
)
|
||
|
||
assert result.urgency == "высокая"
|
||
assert len(result.primary_zones) == 1
|
||
assert result.search_radius_km == 5.0
|
||
assert result.fallback_used is False
|
||
|
||
|
||
class TestFallbackAnalysis:
|
||
"""Test fallback analysis without Claude API."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fallback_young_child(self):
|
||
"""Test fallback for young child (critical urgency)."""
|
||
case_data = {
|
||
'age': 3,
|
||
'gender': 'мужской',
|
||
'terrain': 'лес',
|
||
'weather': 'ясно'
|
||
}
|
||
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert result.urgency == "критическая"
|
||
assert result.fallback_used is True
|
||
assert len(result.primary_zones) >= 2
|
||
assert "водоёмы" in result.key_locations
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fallback_with_ras_profile(self):
|
||
"""Test fallback with РАС profile."""
|
||
case_data = {
|
||
'age': 8,
|
||
'gender': 'мужской',
|
||
'profiles': ['РАС']
|
||
}
|
||
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert result.urgency == "критическая"
|
||
assert "РАС" in result.behavioral_prediction or "водоём" in result.behavioral_prediction
|
||
assert any("водоём" in action.lower() for action in result.immediate_actions)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fallback_with_bicycle(self):
|
||
"""Test fallback with bicycle profile."""
|
||
case_data = {
|
||
'age': 12,
|
||
'gender': 'мужской',
|
||
'profiles': ['велосипед']
|
||
}
|
||
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert result.urgency == "высокая"
|
||
assert any("10-15 км" in action or "камер" in action for action in result.immediate_actions)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fallback_teenager(self):
|
||
"""Test fallback for teenager."""
|
||
case_data = {
|
||
'age': 15,
|
||
'gender': 'мужской',
|
||
'terrain': 'лес'
|
||
}
|
||
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert result.urgency in ["средняя", "высокая"]
|
||
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fallback_with_coordinates(self):
|
||
"""Test fallback with coordinates (geo service integration)."""
|
||
case_data = {
|
||
'age': 10,
|
||
'lat': 53.9,
|
||
'lon': 27.5,
|
||
'terrain': 'лес'
|
||
}
|
||
|
||
with patch('services.claude_service.build_search_zones', new_callable=AsyncMock) as mock_zones:
|
||
# Mock zones
|
||
from services.geo_service import Zone
|
||
mock_zones.return_value = [
|
||
Zone(
|
||
direction="N",
|
||
distance_km=0.5,
|
||
forest_pct=60.0,
|
||
road_density=1.0,
|
||
water_distance_km=2.0,
|
||
settlement_distance_km=5.0
|
||
),
|
||
Zone(
|
||
direction="E",
|
||
distance_km=1.0,
|
||
forest_pct=40.0,
|
||
road_density=2.0,
|
||
water_distance_km=1.0,
|
||
settlement_distance_km=3.0
|
||
)
|
||
]
|
||
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert result.fallback_used is True
|
||
assert len(result.primary_zones) > 0
|
||
mock_zones.assert_called_once()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fallback_without_coordinates(self):
|
||
"""Test fallback without coordinates (basic zones)."""
|
||
case_data = {
|
||
'age': 10,
|
||
'terrain': 'лес'
|
||
}
|
||
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert result.fallback_used is True
|
||
assert len(result.primary_zones) >= 2
|
||
assert result.primary_zones[0].priority == 1
|
||
|
||
|
||
class TestAnalyzeCase:
|
||
"""Test main analyze_case function."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_analyze_without_api_key(self):
|
||
"""Test analyze falls back when no API key."""
|
||
case_data = {
|
||
'age': 10,
|
||
'gender': 'мужской',
|
||
'terrain': 'лес'
|
||
}
|
||
|
||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': ''}, clear=True):
|
||
result = await analyze_case(case_data)
|
||
|
||
assert result.fallback_used is True
|
||
assert isinstance(result, AnalysisResult)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_analyze_with_api_error(self):
|
||
"""Test analyze falls back on API error."""
|
||
case_data = {
|
||
'age': 10,
|
||
'gender': 'мужской',
|
||
'terrain': 'лес'
|
||
}
|
||
|
||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
|
||
with patch('httpx.AsyncClient') as mock_client:
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 500
|
||
mock_response.text = "Server error"
|
||
|
||
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
|
||
|
||
result = await analyze_case(case_data)
|
||
|
||
# Should fallback
|
||
assert result.fallback_used is True
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_analyze_with_successful_api(self):
|
||
"""Test analyze with successful Claude API response."""
|
||
case_data = {
|
||
'age': 10,
|
||
'gender': 'мужской',
|
||
'terrain': 'лес',
|
||
'weather': 'дождь'
|
||
}
|
||
|
||
mock_api_response = {
|
||
"urgency": "высокая",
|
||
"primary_zones": [
|
||
{
|
||
"priority": 1,
|
||
"name": "Лесной массив север",
|
||
"direction": "N",
|
||
"distance": 1.5,
|
||
"reason": "Наиболее вероятное направление"
|
||
}
|
||
],
|
||
"search_radius_km": 5.0,
|
||
"key_locations": ["водоёмы", "дороги"],
|
||
"behavioral_prediction": "Ребёнок может двигаться по тропам",
|
||
"immediate_actions": ["Организовать поиск", "Проверить водоёмы"],
|
||
"summary": "Случай высокой срочности"
|
||
}
|
||
|
||
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}):
|
||
with patch('httpx.AsyncClient') as mock_client:
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
import json
|
||
json_text = json.dumps(mock_api_response, ensure_ascii=False)
|
||
mock_response.json.return_value = {
|
||
"content": [
|
||
{
|
||
"text": f"```json\n{json_text}\n```"
|
||
}
|
||
]
|
||
}
|
||
|
||
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
|
||
|
||
result = await analyze_case(case_data)
|
||
|
||
assert result.fallback_used is False
|
||
assert result.urgency == "высокая"
|
||
|
||
|
||
class TestUrgencyClassification:
|
||
"""Test urgency classification logic."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_critical_urgency_young_child(self):
|
||
"""Test critical urgency for very young children."""
|
||
case_data = {'age': 2}
|
||
result = await analyze_with_fallback(case_data)
|
||
assert result.urgency == "критическая"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_critical_urgency_epilepsy(self):
|
||
"""Test critical urgency for epilepsy."""
|
||
case_data = {'age': 10, 'profiles': ['эпилепсия']}
|
||
result = await analyze_with_fallback(case_data)
|
||
assert result.urgency == "критическая"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_high_urgency_bicycle(self):
|
||
"""Test high urgency for bicycle."""
|
||
case_data = {'age': 12, 'profiles': ['велосипед']}
|
||
result = await analyze_with_fallback(case_data)
|
||
assert result.urgency == "высокая"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_medium_urgency_preteen(self):
|
||
"""Test medium urgency for preteen."""
|
||
case_data = {'age': 10}
|
||
result = await analyze_with_fallback(case_data)
|
||
assert result.urgency == "средняя"
|
||
|
||
|
||
class TestKeyLocations:
|
||
"""Test key locations based on age."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_young_child_locations(self):
|
||
"""Test key locations for young children."""
|
||
case_data = {'age': 5}
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert "водоёмы" in result.key_locations
|
||
assert "укрытия" in result.key_locations
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_preteen_locations(self):
|
||
"""Test key locations for preteens."""
|
||
case_data = {'age': 10}
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert "водоёмы" in result.key_locations
|
||
assert "дороги" in result.key_locations or "тропы" in result.key_locations
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_teenager_locations(self):
|
||
"""Test key locations for teenagers."""
|
||
case_data = {'age': 15}
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert "дороги" in result.key_locations or "населённые пункты" in result.key_locations
|
||
|
||
|
||
class TestBehavioralPrediction:
|
||
"""Test behavioral prediction logic."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ras_prediction(self):
|
||
"""Test РАС behavioral prediction."""
|
||
case_data = {'age': 8, 'profiles': ['РАС']}
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert "водоём" in result.behavioral_prediction.lower() or "рас" in result.behavioral_prediction.lower()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_young_child_prediction(self):
|
||
"""Test young child behavioral prediction."""
|
||
case_data = {'age': 3}
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert "минимальное" in result.behavioral_prediction.lower() or "близко" in result.behavioral_prediction.lower()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_teenager_prediction(self):
|
||
"""Test teenager behavioral prediction."""
|
||
case_data = {'age': 15}
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert "целенаправленное" in result.behavioral_prediction.lower() or "населённ" in result.behavioral_prediction.lower()
|
||
|
||
|
||
class TestImmediateActions:
|
||
"""Test immediate actions generation."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_basic_actions(self):
|
||
"""Test basic immediate actions are present."""
|
||
case_data = {'age': 10}
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert len(result.immediate_actions) >= 3
|
||
assert any("поиск" in action.lower() for action in result.immediate_actions)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ras_critical_action(self):
|
||
"""Test РАС critical action is first."""
|
||
case_data = {'age': 8, 'profiles': ['РАС']}
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
first_action = result.immediate_actions[0]
|
||
assert "водоём" in first_action.lower() or "критично" in first_action.lower()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_bicycle_action(self):
|
||
"""Test bicycle specific action."""
|
||
case_data = {'age': 12, 'profiles': ['велосипед']}
|
||
result = await analyze_with_fallback(case_data)
|
||
|
||
assert any("10-15" in action or "камер" in action for action in result.immediate_actions)
|