""" 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, _extract_json_payload ) 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) class TestExtractJsonPayload: """Test tolerant JSON extraction from a model response (B2).""" def test_bare_json(self): assert _extract_json_payload('{"a": 1}') == {'a': 1} def test_json_fence(self): assert _extract_json_payload('```json\n{"a": 1}\n```') == {'a': 1} def test_uppercase_json_fence(self): assert _extract_json_payload('```JSON\n{"a": 1}\n```') == {'a': 1} def test_bare_fence(self): assert _extract_json_payload('```\n{"a": 1}\n```') == {'a': 1} def test_prose_around_json(self): content = 'Вот результат анализа:\n{"a": 1}\nНадеюсь, это поможет.' assert _extract_json_payload(content) == {'a': 1} def test_prose_around_fenced_json(self): content = 'Разбор:\n```json\n{"a": 1}\n```\nКонец.' assert _extract_json_payload(content) == {'a': 1} def test_json_array_is_rejected(self): """A top-level array is not a valid AnalysisResult payload.""" with pytest.raises(ValueError): _extract_json_payload('[1, 2, 3]') def test_plain_text_raises(self): with pytest.raises(ValueError): _extract_json_payload('Извините, я не могу выполнить этот запрос.') def test_empty_raises(self): with pytest.raises(ValueError): _extract_json_payload('') def test_none_raises(self): with pytest.raises(ValueError): _extract_json_payload(None) class TestClaudeResponseFallback: """Malformed Claude responses must degrade to scoring, not crash (B2).""" def _mock_client(self, mock_client, text=None, payload=None): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = ( payload if payload is not None else {"content": [{"text": text}]} ) mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) @pytest.mark.asyncio async def test_unparseable_text_falls_back(self): """Model answers in prose instead of JSON -> deterministic fallback.""" with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}): with patch('httpx.AsyncClient') as mock_client: self._mock_client(mock_client, text='Не могу помочь с этим.') result = await analyze_case({'age': 10, 'terrain': 'лес'}) assert result.fallback_used is True assert isinstance(result, AnalysisResult) @pytest.mark.asyncio async def test_truncated_json_falls_back(self): with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}): with patch('httpx.AsyncClient') as mock_client: self._mock_client(mock_client, text='```json\n{"urgency": "высок') result = await analyze_case({'age': 10}) assert result.fallback_used is True @pytest.mark.asyncio async def test_valid_json_missing_required_fields_falls_back(self): """Parseable JSON that violates the AnalysisResult contract.""" with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}): with patch('httpx.AsyncClient') as mock_client: self._mock_client(mock_client, text='{"urgency": "высокая"}') result = await analyze_case({'age': 10}) assert result.fallback_used is True @pytest.mark.asyncio async def test_unexpected_envelope_falls_back(self): """API envelope without content[0].text -> fallback, not KeyError.""" with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}): with patch('httpx.AsyncClient') as mock_client: self._mock_client(mock_client, payload={'unexpected': 'shape'}) result = await analyze_case({'age': 10}) assert result.fallback_used is True @pytest.mark.asyncio async def test_unfenced_json_with_prose_succeeds(self): """Recovery path: valid payload wrapped in prose is still used.""" import json as _json payload = { "urgency": "высокая", "primary_zones": [{ "priority": 1, "name": "Лес север", "direction": "N", "distance": 1.5, "reason": "Вероятное направление" }], "search_radius_km": 5.0, "key_locations": ["водоёмы"], "behavioral_prediction": "Движение по тропам", "immediate_actions": ["Организовать поиск"], "summary": "Резюме" } text = 'Результат:\n' + _json.dumps(payload, ensure_ascii=False) + '\nГотово.' with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'test_key'}): with patch('httpx.AsyncClient') as mock_client: self._mock_client(mock_client, text=text) result = await analyze_case({'age': 10}) assert result.fallback_used is False assert result.urgency == "высокая"