Import Vector lab project
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Tests for distance_service.py
|
||||
|
||||
Tests the distance calculation formulas and prior probabilities
|
||||
based on §9 ВЕКТОР-контекст.md specifications.
|
||||
"""
|
||||
import pytest
|
||||
from services.distance_service import (
|
||||
calculate_max_distance,
|
||||
get_base_speed,
|
||||
get_terrain_coefficient,
|
||||
get_time_of_day_coefficient,
|
||||
get_weather_coefficient,
|
||||
get_distance_priors,
|
||||
get_distance_zone,
|
||||
get_distance_statistics
|
||||
)
|
||||
|
||||
|
||||
class TestCalculateMaxDistance:
|
||||
"""Test the main distance calculation function."""
|
||||
|
||||
def test_boy_10_years_4_hours_forest_rain(self):
|
||||
"""
|
||||
Test case from requirements:
|
||||
Мальчик 10 лет, 4 часа, лес, дождь → ~5 км
|
||||
"""
|
||||
case_data = {
|
||||
'age': 10,
|
||||
'elapsed_hours': 4.0,
|
||||
'terrain_primary': 'лес',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'дождь'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected calculation:
|
||||
# Time = 4 hours
|
||||
# НормС (age 10) = 4.0 km/h
|
||||
# СП (лес) = 0.5
|
||||
# СУТ (4 hours) = 1.0 - (0.05 * 4) = 0.8
|
||||
# ВВС (день) = 1.0
|
||||
# ВП (дождь) = 0.8
|
||||
# Distance = 4 * 4.0 * 0.5 * 0.8 * 1.0 * 0.8 = 5.12 km
|
||||
|
||||
assert 4.5 <= distance <= 5.5, f"Expected ~5 km, got {distance} km"
|
||||
assert distance == pytest.approx(5.12, rel=0.01)
|
||||
|
||||
def test_young_child_short_time(self):
|
||||
"""Test for young child (3 years) with short elapsed time."""
|
||||
case_data = {
|
||||
'age': 3,
|
||||
'elapsed_hours': 1.0,
|
||||
'terrain_primary': 'лес',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'нет'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 1 * 2.0 * 0.5 * 0.95 * 1.0 * 1.0 = 0.95 km
|
||||
assert distance == pytest.approx(0.95, rel=0.01)
|
||||
|
||||
def test_teenager_long_time_road(self):
|
||||
"""Test for teenager on road with longer elapsed time."""
|
||||
case_data = {
|
||||
'age': 15,
|
||||
'elapsed_hours': 6.0,
|
||||
'terrain_primary': 'дорога',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'нет'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 6 * 5.0 * 0.8 * 0.7 * 1.0 * 1.0 = 16.8 km
|
||||
assert distance == pytest.approx(16.8, rel=0.01)
|
||||
|
||||
def test_elderly_night_swamp(self):
|
||||
"""Test for elderly person at night in swamp."""
|
||||
case_data = {
|
||||
'age': 70,
|
||||
'elapsed_hours': 3.0,
|
||||
'terrain_primary': 'болото',
|
||||
'time_of_day': 'ночь',
|
||||
'weather': 'туман'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 3 * 3.0 * 0.2 * 0.85 * 0.5 * 0.7 = 0.54 km
|
||||
assert distance == pytest.approx(0.54, rel=0.01)
|
||||
|
||||
def test_adult_heavy_rain_field(self):
|
||||
"""Test for adult in heavy rain on field."""
|
||||
case_data = {
|
||||
'age': 35,
|
||||
'elapsed_hours': 2.0,
|
||||
'terrain_primary': 'поле',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'ливень'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# Expected: 2 * 5.0 * 0.9 * 0.9 * 1.0 * 0.6 = 4.86 km
|
||||
assert distance == pytest.approx(4.86, rel=0.01)
|
||||
|
||||
|
||||
class TestBaseSpeed:
|
||||
"""Test НормС (base speed) by age."""
|
||||
|
||||
def test_infant(self):
|
||||
assert get_base_speed(1) == 1.0
|
||||
assert get_base_speed(2) == 1.0
|
||||
|
||||
def test_preschool(self):
|
||||
assert get_base_speed(3) == 2.0
|
||||
assert get_base_speed(5) == 2.0
|
||||
|
||||
def test_young_child(self):
|
||||
assert get_base_speed(6) == 3.0
|
||||
assert get_base_speed(8) == 3.0
|
||||
|
||||
def test_preteen(self):
|
||||
assert get_base_speed(10) == 4.0
|
||||
assert get_base_speed(12) == 4.0
|
||||
|
||||
def test_teenager(self):
|
||||
assert get_base_speed(13) == 5.0
|
||||
assert get_base_speed(15) == 5.0
|
||||
assert get_base_speed(16) == 5.5
|
||||
assert get_base_speed(17) == 5.5
|
||||
|
||||
def test_adult(self):
|
||||
assert get_base_speed(25) == 5.0
|
||||
assert get_base_speed(50) == 5.0
|
||||
assert get_base_speed(64) == 5.0
|
||||
|
||||
def test_elderly(self):
|
||||
assert get_base_speed(65) == 3.0
|
||||
assert get_base_speed(80) == 3.0
|
||||
|
||||
|
||||
class TestTerrainCoefficient:
|
||||
"""Test СП (terrain coefficient)."""
|
||||
|
||||
def test_road_terrain(self):
|
||||
assert get_terrain_coefficient('дорога') == 0.8
|
||||
assert get_terrain_coefficient('лесная дорога') == 0.8
|
||||
assert get_terrain_coefficient('тропа') == 0.8
|
||||
|
||||
def test_forest_terrain(self):
|
||||
assert get_terrain_coefficient('лес') == 0.5
|
||||
assert get_terrain_coefficient('простой лес') == 0.5
|
||||
assert get_terrain_coefficient('сложный лес') == 0.25
|
||||
assert get_terrain_coefficient('густой лес') == 0.25
|
||||
|
||||
def test_open_terrain(self):
|
||||
assert get_terrain_coefficient('поле') == 0.9
|
||||
assert get_terrain_coefficient('луг') == 0.9
|
||||
|
||||
def test_difficult_terrain(self):
|
||||
assert get_terrain_coefficient('болото') == 0.2
|
||||
assert get_terrain_coefficient('горы') == 0.3
|
||||
assert get_terrain_coefficient('овраг') == 0.3
|
||||
|
||||
def test_urban_terrain(self):
|
||||
assert get_terrain_coefficient('город') == 1.0
|
||||
assert get_terrain_coefficient('населённый пункт') == 1.0
|
||||
|
||||
def test_unknown_terrain(self):
|
||||
assert get_terrain_coefficient('неизвестно') == 0.5
|
||||
|
||||
|
||||
class TestTimeOfDayCoefficient:
|
||||
"""Test ВВС (time of day coefficient)."""
|
||||
|
||||
def test_day(self):
|
||||
assert get_time_of_day_coefficient('день') == 1.0
|
||||
|
||||
def test_night(self):
|
||||
assert get_time_of_day_coefficient('ночь') == 0.5
|
||||
|
||||
def test_twilight(self):
|
||||
assert get_time_of_day_coefficient('сумерки') == 0.5
|
||||
assert get_time_of_day_coefficient('вечер') == 0.5
|
||||
|
||||
|
||||
class TestWeatherCoefficient:
|
||||
"""Test ВП (weather coefficient)."""
|
||||
|
||||
def test_clear_weather(self):
|
||||
assert get_weather_coefficient('нет') == 1.0
|
||||
assert get_weather_coefficient('ясно') == 1.0
|
||||
|
||||
def test_rain(self):
|
||||
assert get_weather_coefficient('дождь') == 0.8
|
||||
assert get_weather_coefficient('ливень') == 0.6
|
||||
assert get_weather_coefficient('сильный дождь') == 0.6
|
||||
|
||||
def test_fog(self):
|
||||
assert get_weather_coefficient('туман') == 0.7
|
||||
|
||||
def test_snow(self):
|
||||
assert get_weather_coefficient('снег') == 0.6
|
||||
assert get_weather_coefficient('метель') == 0.6
|
||||
|
||||
def test_heat(self):
|
||||
assert get_weather_coefficient('жара') == 0.8
|
||||
|
||||
|
||||
class TestDistancePriors:
|
||||
"""Test get_distance_priors() - априорные вероятности зон."""
|
||||
|
||||
def test_young_child_priors(self):
|
||||
"""Children under 8 stay close."""
|
||||
priors = get_distance_priors(5)
|
||||
|
||||
assert priors['0_500m'] == 0.45
|
||||
assert priors['500_1500m'] == 0.35
|
||||
assert priors['1500_2500m'] == 0.15
|
||||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
def test_preteen_priors(self):
|
||||
"""Children 8-12 have more even distribution."""
|
||||
priors = get_distance_priors(10)
|
||||
|
||||
assert priors['0_500m'] == 0.28
|
||||
assert priors['500_1500m'] == 0.25
|
||||
assert priors['1500_2500m'] == 0.22
|
||||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
def test_teenager_priors(self):
|
||||
"""Teenagers can go farther."""
|
||||
priors = get_distance_priors(15)
|
||||
|
||||
assert priors['1500_2500m'] == 0.25
|
||||
assert priors['5000_plus'] == 0.08
|
||||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
def test_adult_priors(self):
|
||||
"""Adults have highest far-distance probability."""
|
||||
priors = get_distance_priors(35)
|
||||
|
||||
assert priors['0_500m'] == 0.12
|
||||
assert priors['5000_plus'] == 0.13
|
||||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
def test_elderly_priors(self):
|
||||
"""Elderly stay closer like young children."""
|
||||
priors = get_distance_priors(70)
|
||||
|
||||
assert priors['0_500m'] == 0.35
|
||||
assert priors['5000_plus'] == 0.02
|
||||
assert sum(priors.values()) == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
|
||||
class TestDistanceZone:
|
||||
"""Test get_distance_zone() classification."""
|
||||
|
||||
def test_zone_classification(self):
|
||||
assert get_distance_zone(0.3) == '0_500m'
|
||||
assert get_distance_zone(0.5) == '500_1500m'
|
||||
assert get_distance_zone(1.0) == '500_1500m'
|
||||
assert get_distance_zone(1.5) == '1500_2500m'
|
||||
assert get_distance_zone(2.5) == '2500_3500m'
|
||||
assert get_distance_zone(4.0) == '3500_5000m'
|
||||
assert get_distance_zone(6.0) == '5000_plus'
|
||||
|
||||
|
||||
class TestDistanceStatistics:
|
||||
"""Test get_distance_statistics() comprehensive output."""
|
||||
|
||||
def test_statistics_structure(self):
|
||||
stats = get_distance_statistics(
|
||||
age_years=10,
|
||||
elapsed_hours=4.0,
|
||||
terrain='лес'
|
||||
)
|
||||
|
||||
assert 'max_distance_km' in stats
|
||||
assert 'current_zone' in stats
|
||||
assert 'zone_probability' in stats
|
||||
assert 'all_priors' in stats
|
||||
assert 'base_speed_kmh' in stats
|
||||
assert 'terrain_coefficient' in stats
|
||||
|
||||
def test_statistics_values(self):
|
||||
stats = get_distance_statistics(
|
||||
age_years=10,
|
||||
elapsed_hours=4.0,
|
||||
terrain='лес'
|
||||
)
|
||||
|
||||
assert stats['base_speed_kmh'] == 4.0
|
||||
assert stats['terrain_coefficient'] == 0.5
|
||||
assert stats['max_distance_km'] > 0
|
||||
assert 0 <= stats['zone_probability'] <= 1.0
|
||||
|
||||
|
||||
class TestFatigueCoefficient:
|
||||
"""Test СУТ (fatigue coefficient) behavior."""
|
||||
|
||||
def test_fatigue_progression(self):
|
||||
"""Fatigue increases with time (5% per hour)."""
|
||||
case_1h = {
|
||||
'age': 30,
|
||||
'elapsed_hours': 1.0,
|
||||
'terrain_primary': 'поле',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'нет'
|
||||
}
|
||||
dist_1h = calculate_max_distance(case_1h)
|
||||
|
||||
case_5h = case_1h.copy()
|
||||
case_5h['elapsed_hours'] = 5.0
|
||||
dist_5h = calculate_max_distance(case_5h)
|
||||
|
||||
case_10h = case_1h.copy()
|
||||
case_10h['elapsed_hours'] = 10.0
|
||||
dist_10h = calculate_max_distance(case_10h)
|
||||
|
||||
assert dist_5h < dist_1h * 5
|
||||
assert dist_10h < dist_5h * 2
|
||||
|
||||
def test_fatigue_minimum(self):
|
||||
"""Fatigue coefficient has minimum of 0.3."""
|
||||
case_data = {
|
||||
'age': 30,
|
||||
'elapsed_hours': 20.0,
|
||||
'terrain_primary': 'поле',
|
||||
'time_of_day': 'день',
|
||||
'weather': 'нет'
|
||||
}
|
||||
|
||||
distance = calculate_max_distance(case_data)
|
||||
|
||||
# 20 * 5.0 * 0.9 * 0.3 * 1.0 * 1.0 = 27.0
|
||||
assert distance == pytest.approx(27.0, rel=0.01)
|
||||
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Tests for geo_service.py
|
||||
|
||||
Tests the geographic zone building and Overpass API integration.
|
||||
"""
|
||||
import pytest
|
||||
import math
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
|
||||
from services.geo_service import (
|
||||
haversine,
|
||||
get_sector_bounds,
|
||||
get_cache_key,
|
||||
get_cached_result,
|
||||
save_to_cache,
|
||||
calculate_road_length,
|
||||
find_nearest_distance,
|
||||
calculate_forest_coverage,
|
||||
build_search_zones,
|
||||
DIRECTIONS,
|
||||
SEARCH_DISTANCES,
|
||||
CACHE_DIR
|
||||
)
|
||||
|
||||
|
||||
class TestHaversine:
|
||||
"""Test haversine distance calculation."""
|
||||
|
||||
def test_same_point(self):
|
||||
"""Test distance between same point is zero."""
|
||||
distance = haversine(53.9, 27.5, 53.9, 27.5)
|
||||
assert distance == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
def test_known_distance(self):
|
||||
"""Test known distance between cities."""
|
||||
# Minsk to Brest approximately 350 km
|
||||
minsk_lat, minsk_lon = 53.9, 27.5
|
||||
brest_lat, brest_lon = 52.1, 23.7
|
||||
|
||||
distance = haversine(minsk_lat, minsk_lon, brest_lat, brest_lon)
|
||||
|
||||
# Should be around 350 km
|
||||
assert 300 < distance < 400
|
||||
|
||||
def test_short_distance(self):
|
||||
"""Test short distance calculation."""
|
||||
# 1 km north
|
||||
lat1, lon1 = 53.9, 27.5
|
||||
lat2 = lat1 + 0.009 # ~1 km
|
||||
lon2 = lon1
|
||||
|
||||
distance = haversine(lat1, lon1, lat2, lon2)
|
||||
assert distance == pytest.approx(1.0, abs=0.1)
|
||||
|
||||
|
||||
class TestSectorBounds:
|
||||
"""Test sector boundary calculations."""
|
||||
|
||||
def test_north_sector(self):
|
||||
"""Test north sector bounds."""
|
||||
lat, lon = 53.9, 27.5
|
||||
bounds = get_sector_bounds(lat, lon, "N", 1000)
|
||||
|
||||
min_lat, min_lon, max_lat, max_lon = bounds
|
||||
|
||||
# North sector should extend north
|
||||
assert max_lat > lat
|
||||
assert isinstance(min_lat, float)
|
||||
assert isinstance(max_lat, float)
|
||||
|
||||
def test_all_directions(self):
|
||||
"""Test all 8 directions return valid bounds."""
|
||||
lat, lon = 53.9, 27.5
|
||||
|
||||
for direction in DIRECTIONS:
|
||||
bounds = get_sector_bounds(lat, lon, direction, 1000)
|
||||
min_lat, min_lon, max_lat, max_lon = bounds
|
||||
|
||||
assert min_lat < max_lat
|
||||
assert min_lon < max_lon
|
||||
assert all(isinstance(x, float) for x in bounds)
|
||||
|
||||
def test_different_radii(self):
|
||||
"""Test different radii produce different bounds."""
|
||||
lat, lon = 53.9, 27.5
|
||||
|
||||
bounds_500 = get_sector_bounds(lat, lon, "N", 500)
|
||||
bounds_5000 = get_sector_bounds(lat, lon, "N", 5000)
|
||||
|
||||
# Larger radius should have larger bounds
|
||||
assert (bounds_5000[2] - bounds_5000[0]) > (bounds_500[2] - bounds_500[0])
|
||||
|
||||
|
||||
class TestCaching:
|
||||
"""Test caching functionality."""
|
||||
|
||||
def test_cache_key_generation(self):
|
||||
"""Test cache key is consistent."""
|
||||
query = "test query"
|
||||
key1 = get_cache_key(query)
|
||||
key2 = get_cache_key(query)
|
||||
|
||||
assert key1 == key2
|
||||
assert len(key1) == 32 # MD5 hash length
|
||||
|
||||
def test_cache_key_different_queries(self):
|
||||
"""Test different queries produce different keys."""
|
||||
key1 = get_cache_key("query 1")
|
||||
key2 = get_cache_key("query 2")
|
||||
|
||||
assert key1 != key2
|
||||
|
||||
def test_save_and_get_cache(self):
|
||||
"""Test saving and retrieving from cache."""
|
||||
cache_key = "test_key_123"
|
||||
test_data = {'elements': [{'id': 1, 'type': 'node'}]}
|
||||
|
||||
# Save to cache
|
||||
save_to_cache(cache_key, test_data)
|
||||
|
||||
# Retrieve from cache
|
||||
cached = get_cached_result(cache_key)
|
||||
|
||||
assert cached is not None
|
||||
assert cached == test_data
|
||||
|
||||
# Cleanup
|
||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
||||
if cache_file.exists():
|
||||
cache_file.unlink()
|
||||
|
||||
def test_cache_expiration(self):
|
||||
"""Test cache expires after TTL."""
|
||||
cache_key = "test_key_expired"
|
||||
test_data = {'elements': []}
|
||||
|
||||
# Save to cache with old timestamp
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
cache_file = CACHE_DIR / f"{cache_key}.json"
|
||||
|
||||
old_time = datetime.now() - timedelta(hours=25)
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump({
|
||||
'timestamp': old_time.isoformat(),
|
||||
'data': test_data
|
||||
}, f)
|
||||
|
||||
# Should return None (expired)
|
||||
cached = get_cached_result(cache_key)
|
||||
assert cached is None
|
||||
|
||||
# Cleanup
|
||||
if cache_file.exists():
|
||||
cache_file.unlink()
|
||||
|
||||
def test_cache_not_found(self):
|
||||
"""Test cache returns None for non-existent key."""
|
||||
cached = get_cached_result("nonexistent_key_xyz")
|
||||
assert cached is None
|
||||
|
||||
|
||||
class TestRoadLength:
|
||||
"""Test road length calculation."""
|
||||
|
||||
def test_empty_elements(self):
|
||||
"""Test empty elements returns zero."""
|
||||
length = calculate_road_length([])
|
||||
assert length == 0.0
|
||||
|
||||
def test_single_way(self):
|
||||
"""Test single way calculation."""
|
||||
elements = [
|
||||
{
|
||||
'type': 'way',
|
||||
'geometry': [
|
||||
{'lat': 53.9, 'lon': 27.5},
|
||||
{'lat': 53.91, 'lon': 27.5}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
length = calculate_road_length(elements)
|
||||
|
||||
# Should be approximately 1.1 km
|
||||
assert 0.5 < length < 2.0
|
||||
|
||||
def test_multiple_ways(self):
|
||||
"""Test multiple ways are summed."""
|
||||
elements = [
|
||||
{
|
||||
'type': 'way',
|
||||
'geometry': [
|
||||
{'lat': 53.9, 'lon': 27.5},
|
||||
{'lat': 53.91, 'lon': 27.5}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type': 'way',
|
||||
'geometry': [
|
||||
{'lat': 53.9, 'lon': 27.5},
|
||||
{'lat': 53.9, 'lon': 27.51}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
length = calculate_road_length(elements)
|
||||
assert length > 0
|
||||
|
||||
def test_ignores_non_ways(self):
|
||||
"""Test non-way elements are ignored."""
|
||||
elements = [
|
||||
{'type': 'node', 'lat': 53.9, 'lon': 27.5},
|
||||
{
|
||||
'type': 'way',
|
||||
'geometry': [
|
||||
{'lat': 53.9, 'lon': 27.5},
|
||||
{'lat': 53.91, 'lon': 27.5}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
length = calculate_road_length(elements)
|
||||
assert length > 0
|
||||
|
||||
|
||||
class TestNearestDistance:
|
||||
"""Test nearest distance calculation."""
|
||||
|
||||
def test_empty_elements(self):
|
||||
"""Test empty elements returns None."""
|
||||
distance = find_nearest_distance(53.9, 27.5, [])
|
||||
assert distance is None
|
||||
|
||||
def test_single_node(self):
|
||||
"""Test single node distance."""
|
||||
elements = [
|
||||
{'type': 'node', 'lat': 53.91, 'lon': 27.5}
|
||||
]
|
||||
|
||||
distance = find_nearest_distance(53.9, 27.5, elements)
|
||||
|
||||
assert distance is not None
|
||||
assert distance > 0
|
||||
|
||||
def test_finds_nearest(self):
|
||||
"""Test finds nearest among multiple nodes."""
|
||||
elements = [
|
||||
{'type': 'node', 'lat': 53.95, 'lon': 27.5}, # Far
|
||||
{'type': 'node', 'lat': 53.901, 'lon': 27.5}, # Near
|
||||
{'type': 'node', 'lat': 54.0, 'lon': 27.5} # Very far
|
||||
]
|
||||
|
||||
distance = find_nearest_distance(53.9, 27.5, elements)
|
||||
|
||||
# Should find the nearest (53.901)
|
||||
assert distance < 0.2
|
||||
|
||||
def test_ignores_non_nodes(self):
|
||||
"""Test non-node elements are ignored."""
|
||||
elements = [
|
||||
{'type': 'way', 'geometry': []},
|
||||
{'type': 'node', 'lat': 53.91, 'lon': 27.5}
|
||||
]
|
||||
|
||||
distance = find_nearest_distance(53.9, 27.5, elements)
|
||||
assert distance is not None
|
||||
|
||||
|
||||
class TestForestCoverage:
|
||||
"""Test forest coverage calculation."""
|
||||
|
||||
def test_no_forest(self):
|
||||
"""Test no forest returns 0%."""
|
||||
coverage = calculate_forest_coverage([], 1000)
|
||||
assert coverage == 0.0
|
||||
|
||||
def test_some_forest(self):
|
||||
"""Test forest coverage calculation."""
|
||||
elements = [
|
||||
{'type': 'way', 'tags': {'landuse': 'forest'}},
|
||||
{'type': 'way', 'tags': {'natural': 'wood'}}
|
||||
]
|
||||
|
||||
coverage = calculate_forest_coverage(elements, 1000)
|
||||
|
||||
assert 0 < coverage <= 100
|
||||
|
||||
def test_coverage_capped_at_100(self):
|
||||
"""Test coverage is capped at 100%."""
|
||||
# Many forest ways
|
||||
elements = [{'type': 'way'} for _ in range(1000)]
|
||||
|
||||
coverage = calculate_forest_coverage(elements, 100)
|
||||
|
||||
assert coverage <= 100.0
|
||||
|
||||
|
||||
class TestBuildSearchZones:
|
||||
"""Test search zone building."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zone_count(self):
|
||||
"""Test correct number of zones are created."""
|
||||
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
|
||||
mock_features.return_value = {
|
||||
'roads_km': 5.0,
|
||||
'road_density': 2.0,
|
||||
'water_distance_km': 1.5,
|
||||
'settlement_distance_km': 3.0,
|
||||
'forest_pct': 40.0
|
||||
}
|
||||
|
||||
zones = await build_search_zones(53.9, 27.5, {})
|
||||
|
||||
# 8 directions × 4 distances = 32 zones
|
||||
assert len(zones) == 32
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_directions_covered(self):
|
||||
"""Test all 8 directions are included."""
|
||||
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
|
||||
mock_features.return_value = {
|
||||
'roads_km': 5.0,
|
||||
'road_density': 2.0,
|
||||
'water_distance_km': 1.5,
|
||||
'settlement_distance_km': 3.0,
|
||||
'forest_pct': 40.0
|
||||
}
|
||||
|
||||
zones = await build_search_zones(53.9, 27.5, {})
|
||||
|
||||
directions_found = set(z.direction for z in zones)
|
||||
assert directions_found == set(DIRECTIONS)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_distances_covered(self):
|
||||
"""Test all 4 distances are included."""
|
||||
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
|
||||
mock_features.return_value = {
|
||||
'roads_km': 5.0,
|
||||
'road_density': 2.0,
|
||||
'water_distance_km': 1.5,
|
||||
'settlement_distance_km': 3.0,
|
||||
'forest_pct': 40.0
|
||||
}
|
||||
|
||||
zones = await build_search_zones(53.9, 27.5, {})
|
||||
|
||||
distances_found = set(z.distance_km for z in zones)
|
||||
expected_distances = set(d / 1000 for d in SEARCH_DISTANCES)
|
||||
assert distances_found == expected_distances
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zone_structure(self):
|
||||
"""Test zone objects have correct structure."""
|
||||
with patch('services.geo_service.get_zone_features', new_callable=AsyncMock) as mock_features:
|
||||
mock_features.return_value = {
|
||||
'roads_km': 5.0,
|
||||
'road_density': 2.0,
|
||||
'water_distance_km': 1.5,
|
||||
'settlement_distance_km': 3.0,
|
||||
'forest_pct': 40.0
|
||||
}
|
||||
|
||||
zones = await build_search_zones(53.9, 27.5, {})
|
||||
|
||||
for zone in zones:
|
||||
assert hasattr(zone, 'direction')
|
||||
assert hasattr(zone, 'distance_km')
|
||||
assert hasattr(zone, 'forest_pct')
|
||||
assert hasattr(zone, 'road_density')
|
||||
assert hasattr(zone, 'water_distance_km')
|
||||
assert hasattr(zone, 'settlement_distance_km')
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Test module constants."""
|
||||
|
||||
def test_directions_count(self):
|
||||
"""Test there are 8 directions."""
|
||||
assert len(DIRECTIONS) == 8
|
||||
|
||||
def test_directions_values(self):
|
||||
"""Test direction values are correct."""
|
||||
expected = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
||||
assert DIRECTIONS == expected
|
||||
|
||||
def test_search_distances(self):
|
||||
"""Test search distances are correct."""
|
||||
expected = [500, 1000, 2000, 5000]
|
||||
assert SEARCH_DISTANCES == expected
|
||||
|
||||
def test_cache_dir_path(self):
|
||||
"""Test cache directory path is set."""
|
||||
assert isinstance(CACHE_DIR, Path)
|
||||
assert str(CACHE_DIR) == "/tmp/overpass_cache"
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Tests for psychotype_service.py
|
||||
|
||||
Tests the psychotype detection logic and modifiers
|
||||
based on §6 ВЕКТОР-контекст.md specifications.
|
||||
"""
|
||||
import pytest
|
||||
from services.psychotype_service import (
|
||||
detect_psychotype,
|
||||
get_psychotype_modifiers,
|
||||
get_search_recommendations,
|
||||
get_psychotype_questions
|
||||
)
|
||||
|
||||
|
||||
class TestDetectPsychotype:
|
||||
"""Test psychotype detection from answers."""
|
||||
|
||||
def test_dominant_profile(self):
|
||||
"""Test dominant psychotype: активно + лидер + рискует."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'explore',
|
||||
'stress_reaction': 'angry',
|
||||
'leadership': 'always_leader',
|
||||
'risk_taking': 'very'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'dominant'
|
||||
|
||||
def test_harmonic_profile(self):
|
||||
"""Test harmonic psychotype: спокойно + лидер + осторожный."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'wait',
|
||||
'stress_reaction': 'calm',
|
||||
'leadership': 'always_leader',
|
||||
'risk_taking': 'no_cautious'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'harmonic'
|
||||
|
||||
def test_anxious_profile(self):
|
||||
"""Test anxious psychotype: плачет + ведомый + осторожный."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'wait',
|
||||
'stress_reaction': 'cry',
|
||||
'leadership': 'always_follower',
|
||||
'risk_taking': 'no_cautious'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'anxious'
|
||||
|
||||
def test_introvert_passive_profile(self):
|
||||
"""Test introvert_passive: замирает + ведомый + осторожный."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'freeze',
|
||||
'stress_reaction': 'angry',
|
||||
'leadership': 'always_follower',
|
||||
'risk_taking': 'no_cautious'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'introvert_passive'
|
||||
|
||||
def test_introvert_active_profile(self):
|
||||
"""Test introvert_active: активно + иногда лидер."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'explore',
|
||||
'stress_reaction': 'calm',
|
||||
'leadership': 'sometimes',
|
||||
'risk_taking': 'sometimes'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'introvert_active'
|
||||
|
||||
def test_introvert_active_panic_variant(self):
|
||||
"""Test introvert_active: паникует + иногда лидер."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'panic',
|
||||
'stress_reaction': 'angry',
|
||||
'leadership': 'sometimes',
|
||||
'risk_taking': 'sometimes'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'introvert_active'
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""Test that detection is case-insensitive."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'EXPLORE',
|
||||
'stress_reaction': 'ANGRY',
|
||||
'leadership': 'ALWAYS_LEADER',
|
||||
'risk_taking': 'VERY'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'dominant'
|
||||
|
||||
def test_partial_answers_default_harmonic(self):
|
||||
"""Test that incomplete answers default to harmonic."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'wait',
|
||||
'stress_reaction': 'calm'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
assert psychotype == 'harmonic'
|
||||
|
||||
|
||||
class TestGetPsychotypeModifiers:
|
||||
"""Test psychotype modifiers for search zones."""
|
||||
|
||||
def test_dominant_modifiers(self):
|
||||
"""Test dominant modifiers: far zones emphasized."""
|
||||
modifiers = get_psychotype_modifiers('dominant')
|
||||
|
||||
assert modifiers['zone_0_500'] == 0.7
|
||||
assert modifiers['zone_1500_2500'] == 1.4
|
||||
assert modifiers['movement_model'] == 'chaotic_far'
|
||||
assert 'description' in modifiers
|
||||
|
||||
def test_harmonic_modifiers(self):
|
||||
"""Test harmonic modifiers: balanced distribution."""
|
||||
modifiers = get_psychotype_modifiers('harmonic')
|
||||
|
||||
assert modifiers['zone_0_500'] == 0.8
|
||||
assert modifiers['zone_500_1500'] == 1.0
|
||||
assert modifiers['zone_1500_2500'] == 1.1
|
||||
assert modifiers['movement_model'] == 'linear_landmark'
|
||||
|
||||
def test_anxious_modifiers(self):
|
||||
"""Test anxious modifiers: near zone emphasized."""
|
||||
modifiers = get_psychotype_modifiers('anxious')
|
||||
|
||||
assert modifiers['zone_0_500'] == 1.4
|
||||
assert modifiers['zone_1500_2500'] == 0.5
|
||||
assert modifiers['zone_2500plus'] == 0.3
|
||||
assert modifiers['movement_model'] == 'stay'
|
||||
|
||||
def test_introvert_passive_modifiers(self):
|
||||
"""Test introvert_passive modifiers: very near zone."""
|
||||
modifiers = get_psychotype_modifiers('introvert_passive')
|
||||
|
||||
assert modifiers['zone_0_500'] == 1.3
|
||||
assert modifiers['zone_2500plus'] == 0.2
|
||||
assert modifiers['movement_model'] == 'stay_hidden'
|
||||
|
||||
def test_introvert_active_modifiers(self):
|
||||
"""Test introvert_active modifiers: medium zones."""
|
||||
modifiers = get_psychotype_modifiers('introvert_active')
|
||||
|
||||
assert modifiers['zone_0_500'] == 0.8
|
||||
assert modifiers['zone_500_1500'] == 1.1
|
||||
assert modifiers['zone_1500_2500'] == 1.2
|
||||
assert modifiers['movement_model'] == 'linear_landmark'
|
||||
|
||||
def test_unknown_psychotype_defaults_harmonic(self):
|
||||
"""Test that unknown psychotype returns harmonic modifiers."""
|
||||
modifiers = get_psychotype_modifiers('unknown_type')
|
||||
harmonic_modifiers = get_psychotype_modifiers('harmonic')
|
||||
|
||||
assert modifiers == harmonic_modifiers
|
||||
|
||||
def test_all_modifiers_have_required_fields(self):
|
||||
"""Test that all psychotypes have required modifier fields."""
|
||||
psychotypes = ['dominant', 'harmonic', 'anxious', 'introvert_passive', 'introvert_active']
|
||||
required_fields = ['zone_0_500', 'zone_500_1500', 'zone_1500_2500',
|
||||
'zone_2500plus', 'movement_model', 'description']
|
||||
|
||||
for psychotype in psychotypes:
|
||||
modifiers = get_psychotype_modifiers(psychotype)
|
||||
for field in required_fields:
|
||||
assert field in modifiers, f"{psychotype} missing {field}"
|
||||
|
||||
|
||||
class TestGetSearchRecommendations:
|
||||
"""Test search recommendations for each psychotype."""
|
||||
|
||||
def test_dominant_recommendations(self):
|
||||
"""Test dominant search recommendations."""
|
||||
recs = get_search_recommendations('dominant')
|
||||
|
||||
assert 'priority_zones' in recs
|
||||
assert 'search_pattern' in recs
|
||||
assert 'key_locations' in recs
|
||||
assert 'communication' in recs
|
||||
|
||||
def test_anxious_recommendations(self):
|
||||
"""Test anxious search recommendations."""
|
||||
recs = get_search_recommendations('anxious')
|
||||
|
||||
assert '0-500' in recs['priority_zones']
|
||||
|
||||
def test_all_psychotypes_have_recommendations(self):
|
||||
"""Test that all psychotypes have complete recommendations."""
|
||||
psychotypes = ['dominant', 'harmonic', 'anxious', 'introvert_passive', 'introvert_active']
|
||||
required_fields = ['priority_zones', 'search_pattern', 'key_locations', 'communication']
|
||||
|
||||
for psychotype in psychotypes:
|
||||
recs = get_search_recommendations(psychotype)
|
||||
for field in required_fields:
|
||||
assert field in recs, f"{psychotype} missing {field}"
|
||||
assert len(recs[field]) > 0, f"{psychotype} {field} is empty"
|
||||
|
||||
|
||||
class TestGetPsychotypeQuestions:
|
||||
"""Test psychotype questions structure."""
|
||||
|
||||
def test_questions_count(self):
|
||||
"""Test that there are exactly 4 questions."""
|
||||
questions = get_psychotype_questions()
|
||||
assert len(questions) == 4
|
||||
|
||||
def test_questions_structure(self):
|
||||
"""Test that each question has required fields."""
|
||||
questions = get_psychotype_questions()
|
||||
|
||||
for q in questions:
|
||||
assert 'id' in q
|
||||
assert 'question' in q
|
||||
assert 'options' in q
|
||||
assert len(q['options']) >= 3
|
||||
|
||||
def test_question_ids(self):
|
||||
"""Test that question IDs match expected fields."""
|
||||
questions = get_psychotype_questions()
|
||||
expected_ids = ['unfamiliar_behavior', 'stress_reaction', 'leadership', 'risk_taking']
|
||||
|
||||
actual_ids = [q['id'] for q in questions]
|
||||
assert actual_ids == expected_ids
|
||||
|
||||
def test_options_structure(self):
|
||||
"""Test that each option has value and label."""
|
||||
questions = get_psychotype_questions()
|
||||
|
||||
for q in questions:
|
||||
for option in q['options']:
|
||||
assert 'value' in option
|
||||
assert 'label' in option
|
||||
assert len(option['value']) > 0
|
||||
assert len(option['label']) > 0
|
||||
|
||||
|
||||
class TestPsychotypeIntegration:
|
||||
"""Integration tests for complete psychotype workflow."""
|
||||
|
||||
def test_full_workflow_dominant(self):
|
||||
"""Test complete workflow for dominant type."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'explore',
|
||||
'stress_reaction': 'angry',
|
||||
'leadership': 'always_leader',
|
||||
'risk_taking': 'very'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
modifiers = get_psychotype_modifiers(psychotype)
|
||||
recommendations = get_search_recommendations(psychotype)
|
||||
|
||||
assert psychotype == 'dominant'
|
||||
assert modifiers['zone_1500_2500'] > modifiers['zone_0_500']
|
||||
assert 'priority_zones' in recommendations
|
||||
|
||||
def test_full_workflow_anxious(self):
|
||||
"""Test complete workflow for anxious type."""
|
||||
answers = {
|
||||
'unfamiliar_behavior': 'freeze',
|
||||
'stress_reaction': 'cry',
|
||||
'leadership': 'always_follower',
|
||||
'risk_taking': 'no_cautious'
|
||||
}
|
||||
|
||||
psychotype = detect_psychotype(answers)
|
||||
modifiers = get_psychotype_modifiers(psychotype)
|
||||
recommendations = get_search_recommendations(psychotype)
|
||||
|
||||
assert psychotype in ['anxious', 'introvert_passive']
|
||||
assert modifiers['zone_0_500'] > 1.0
|
||||
assert '0-500' in recommendations['priority_zones']
|
||||
|
||||
def test_modifier_distributions_differ(self):
|
||||
"""Test that different psychotypes have different modifier distributions."""
|
||||
dominant_mods = get_psychotype_modifiers('dominant')
|
||||
anxious_mods = get_psychotype_modifiers('anxious')
|
||||
|
||||
# Dominant emphasizes far zones
|
||||
assert dominant_mods['zone_1500_2500'] > anxious_mods['zone_1500_2500']
|
||||
|
||||
# Anxious emphasizes near zones
|
||||
assert anxious_mods['zone_0_500'] > dominant_mods['zone_0_500']
|
||||
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
Tests for scoring_service.py
|
||||
|
||||
Tests the WeightedScorer class and zone ranking logic
|
||||
based on §8 and §9 ВЕКТОР-контекст.md specifications.
|
||||
"""
|
||||
import pytest
|
||||
from services.scoring_service import (
|
||||
WeightedScorer,
|
||||
create_scorer_for_case,
|
||||
get_weight_explanation
|
||||
)
|
||||
|
||||
|
||||
class TestWeightedScorerBasics:
|
||||
"""Test basic WeightedScorer functionality."""
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test scorer initializes with base weights."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
assert scorer.weights['forest'] == 0.25
|
||||
assert scorer.weights['water'] == 0.20
|
||||
assert scorer.weights['roads'] == 0.18
|
||||
assert scorer.weights['settlement'] == 0.15
|
||||
assert scorer.weights['historical'] == 0.12
|
||||
assert scorer.weights['direction'] == 0.07
|
||||
assert scorer.weights['shelter'] == 0.03
|
||||
assert scorer.distance_multiplier == 1.0
|
||||
assert scorer.active_profiles == []
|
||||
|
||||
def test_base_weights_sum_to_one(self):
|
||||
"""Test that base weights sum to 1.0."""
|
||||
scorer = WeightedScorer()
|
||||
total = sum(scorer.BASE_WEIGHTS.values())
|
||||
assert total == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
|
||||
class TestAgeModifiers:
|
||||
"""Test age-based modifiers."""
|
||||
|
||||
def test_age_group_0_4(self):
|
||||
"""Test young children (0-4) modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_age_modifiers(3)
|
||||
|
||||
# Young children: high water risk, low distance
|
||||
assert scorer.distance_multiplier == 0.3
|
||||
# Water weight should be increased
|
||||
assert scorer.weights['water'] > scorer.BASE_WEIGHTS['water']
|
||||
|
||||
def test_age_group_8_11(self):
|
||||
"""Test preteen (8-11) modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_age_modifiers(10)
|
||||
|
||||
assert scorer.distance_multiplier == 0.8
|
||||
|
||||
def test_age_group_15_17(self):
|
||||
"""Test teenager (15-17) modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_age_modifiers(16)
|
||||
|
||||
# Teenagers: higher distance multiplier
|
||||
assert scorer.distance_multiplier == 1.5
|
||||
|
||||
|
||||
class TestSeasonModifiers:
|
||||
"""Test seasonal modifiers."""
|
||||
|
||||
def test_winter_modifiers(self):
|
||||
"""Test winter season modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_season_modifiers('зима')
|
||||
|
||||
# Winter: shelter more important, distance reduced
|
||||
assert scorer.distance_multiplier == 0.7
|
||||
assert scorer.weights['shelter'] > scorer.BASE_WEIGHTS['shelter']
|
||||
|
||||
def test_summer_modifiers(self):
|
||||
"""Test summer season modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_season_modifiers('лето')
|
||||
|
||||
# Summer: increased distance
|
||||
assert scorer.distance_multiplier == 1.3
|
||||
|
||||
|
||||
class TestBehavioralProfiles:
|
||||
"""Test behavioral profiles from §8."""
|
||||
|
||||
def test_ras_profile(self):
|
||||
"""Test РАС (autism) profile with critical water/railway emphasis."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС'])
|
||||
|
||||
# РАС: water x3.0, railway x2.5, distance x2.0
|
||||
assert scorer.distance_multiplier == 2.0
|
||||
assert 'РАС' in scorer.active_profiles
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
assert 'водоёмы' in scorer.critical_warnings[0]['warning']
|
||||
|
||||
def test_epilepsy_profile(self):
|
||||
"""Test эпилепсия profile with reduced distance."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['эпилепсия'])
|
||||
|
||||
# Epilepsy: water x3.5, distance x0.6
|
||||
assert scorer.distance_multiplier == 0.6
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
|
||||
def test_bicycle_profile(self):
|
||||
"""Test велосипед profile with massive distance increase."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['велосипед'])
|
||||
|
||||
# Bicycle: distance x5.0, roads x1.8
|
||||
assert scorer.distance_multiplier == 5.0
|
||||
assert 'велосипед' in scorer.active_profiles
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
assert '10-15 км' in scorer.critical_warnings[0]['warning']
|
||||
|
||||
def test_scooter_profile(self):
|
||||
"""Test самокат profile."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['самокат'])
|
||||
|
||||
# Scooter: distance x3.0
|
||||
assert scorer.distance_multiplier == 3.0
|
||||
|
||||
def test_intentional_runaway_profile(self):
|
||||
"""Test намеренный_уход profile."""
|
||||
scorer = WeightedScorer()
|
||||
base_roads = scorer.weights['roads']
|
||||
scorer.apply_profile(['намеренный_уход'])
|
||||
|
||||
# Intentional runaway: roads x2.5, settlement x3.0
|
||||
assert scorer.weights['roads'] > base_roads
|
||||
|
||||
def test_multiple_profiles(self):
|
||||
"""Test applying multiple profiles."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС', 'велосипед'])
|
||||
|
||||
# Both multipliers should compound: 2.0 * 5.0 = 10.0
|
||||
assert scorer.distance_multiplier == 10.0
|
||||
assert len(scorer.active_profiles) == 2
|
||||
|
||||
|
||||
class TestScoreZone:
|
||||
"""Test zone scoring functionality."""
|
||||
|
||||
def test_score_zone_basic(self):
|
||||
"""Test basic zone scoring."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
zone = {
|
||||
'forest_pct': 0.7,
|
||||
'water_distance_km': 2.0,
|
||||
'road_density': 1.0,
|
||||
'settlement_distance_km': 5.0,
|
||||
'historical_freq': 0.6,
|
||||
'direction_match': 0.8,
|
||||
'shelter_pct': 0.4,
|
||||
'distance_km': 2.0
|
||||
}
|
||||
|
||||
case = {'age': 10, 'season': 'лето', 'profiles': []}
|
||||
|
||||
score = scorer.score_zone(zone, case)
|
||||
|
||||
assert 0 <= score <= 100
|
||||
assert isinstance(score, float)
|
||||
|
||||
def test_score_zone_with_ras(self):
|
||||
"""Test zone scoring with РАС profile."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
zone_near_water = {
|
||||
'forest_pct': 0.5,
|
||||
'water_distance_km': 0.5, # Very close to water
|
||||
'road_density': 0.5,
|
||||
'settlement_distance_km': 10.0,
|
||||
'historical_freq': 0.5,
|
||||
'direction_match': 0.5,
|
||||
'shelter_pct': 0.3,
|
||||
'distance_km': 2.0
|
||||
}
|
||||
|
||||
zone_far_water = {
|
||||
'forest_pct': 0.5,
|
||||
'water_distance_km': 5.0, # Far from water
|
||||
'road_density': 0.5,
|
||||
'settlement_distance_km': 10.0,
|
||||
'historical_freq': 0.5,
|
||||
'direction_match': 0.5,
|
||||
'shelter_pct': 0.3,
|
||||
'distance_km': 2.0
|
||||
}
|
||||
|
||||
case = {'age': 8, 'season': 'лето', 'profiles': ['РАС']}
|
||||
|
||||
score_near = scorer.score_zone(zone_near_water, case)
|
||||
score_far = scorer.score_zone(zone_far_water, case)
|
||||
|
||||
# Zone near water should score higher for РАС
|
||||
assert score_near > score_far
|
||||
|
||||
def test_score_zone_with_bicycle(self):
|
||||
"""Test zone scoring with bicycle profile."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
zone_with_roads = {
|
||||
'forest_pct': 0.3,
|
||||
'water_distance_km': 5.0,
|
||||
'road_density': 2.0, # High road density
|
||||
'settlement_distance_km': 5.0,
|
||||
'historical_freq': 0.5,
|
||||
'direction_match': 0.5,
|
||||
'shelter_pct': 0.2,
|
||||
'distance_km': 8.0 # Far distance
|
||||
}
|
||||
|
||||
case = {'age': 12, 'season': 'лето', 'profiles': ['велосипед']}
|
||||
|
||||
score = scorer.score_zone(zone_with_roads, case)
|
||||
|
||||
assert score > 0
|
||||
|
||||
|
||||
class TestRankZones:
|
||||
"""Test zone ranking functionality."""
|
||||
|
||||
def test_rank_zones_basic(self):
|
||||
"""Test basic zone ranking."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
zones = [
|
||||
{
|
||||
'id': 'zone_a',
|
||||
'forest_pct': 0.8,
|
||||
'water_distance_km': 1.0,
|
||||
'road_density': 0.5,
|
||||
'settlement_distance_km': 10.0,
|
||||
'historical_freq': 0.7,
|
||||
'direction_match': 0.9,
|
||||
'shelter_pct': 0.5,
|
||||
'distance_km': 2.0
|
||||
},
|
||||
{
|
||||
'id': 'zone_b',
|
||||
'forest_pct': 0.3,
|
||||
'water_distance_km': 8.0,
|
||||
'road_density': 0.2,
|
||||
'settlement_distance_km': 15.0,
|
||||
'historical_freq': 0.2,
|
||||
'direction_match': 0.3,
|
||||
'shelter_pct': 0.1,
|
||||
'distance_km': 5.0
|
||||
},
|
||||
{
|
||||
'id': 'zone_c',
|
||||
'forest_pct': 0.6,
|
||||
'water_distance_km': 3.0,
|
||||
'road_density': 1.0,
|
||||
'settlement_distance_km': 5.0,
|
||||
'historical_freq': 0.8,
|
||||
'direction_match': 0.7,
|
||||
'shelter_pct': 0.4,
|
||||
'distance_km': 1.5
|
||||
}
|
||||
]
|
||||
|
||||
case = {'age': 10, 'season': 'лето', 'profiles': []}
|
||||
|
||||
ranked = scorer.rank_zones(zones, case)
|
||||
|
||||
assert len(ranked) == 3
|
||||
assert all('score' in z for z in ranked)
|
||||
assert all('priority' in z for z in ranked)
|
||||
|
||||
# Check priorities are 1, 2, 3
|
||||
priorities = [z['priority'] for z in ranked]
|
||||
assert priorities == [1, 2, 3]
|
||||
|
||||
# Check scores are descending
|
||||
scores = [z['score'] for z in ranked]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
def test_rank_zones_with_profiles(self):
|
||||
"""Test zone ranking with behavioral profiles."""
|
||||
scorer = WeightedScorer()
|
||||
|
||||
zones = [
|
||||
{
|
||||
'id': 'near_water',
|
||||
'forest_pct': 0.5,
|
||||
'water_distance_km': 0.3,
|
||||
'road_density': 0.5,
|
||||
'settlement_distance_km': 10.0,
|
||||
'historical_freq': 0.5,
|
||||
'direction_match': 0.5,
|
||||
'shelter_pct': 0.3,
|
||||
'distance_km': 2.0
|
||||
},
|
||||
{
|
||||
'id': 'far_water',
|
||||
'forest_pct': 0.5,
|
||||
'water_distance_km': 8.0,
|
||||
'road_density': 0.5,
|
||||
'settlement_distance_km': 10.0,
|
||||
'historical_freq': 0.5,
|
||||
'direction_match': 0.5,
|
||||
'shelter_pct': 0.3,
|
||||
'distance_km': 2.0
|
||||
}
|
||||
]
|
||||
|
||||
case = {'age': 8, 'season': 'лето', 'profiles': ['РАС']}
|
||||
|
||||
ranked = scorer.rank_zones(zones, case)
|
||||
|
||||
# Zone near water should be priority 1 for РАС
|
||||
assert ranked[0]['id'] == 'near_water'
|
||||
assert ranked[0]['priority'] == 1
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""Test helper functions."""
|
||||
|
||||
def test_create_scorer_for_case(self):
|
||||
"""Test scorer creation for a case."""
|
||||
case = {
|
||||
'age': 10,
|
||||
'season': 'зима',
|
||||
'profiles': ['велосипед']
|
||||
}
|
||||
|
||||
scorer = create_scorer_for_case(case)
|
||||
|
||||
assert scorer.distance_multiplier > 1.0
|
||||
assert 'велосипед' in scorer.active_profiles
|
||||
|
||||
def test_get_weight_explanation(self):
|
||||
"""Test weight explanation generation."""
|
||||
case = {
|
||||
'age': 8,
|
||||
'season': 'лето',
|
||||
'profiles': ['РАС']
|
||||
}
|
||||
|
||||
explanation = get_weight_explanation(case)
|
||||
|
||||
assert 'weights' in explanation
|
||||
assert 'distance_multiplier' in explanation
|
||||
assert 'age_group' in explanation
|
||||
assert 'profiles' in explanation
|
||||
assert 'critical_warnings' in explanation
|
||||
|
||||
assert explanation['age_group'] == '8-11'
|
||||
assert len(explanation['profiles']) == 1
|
||||
assert len(explanation['critical_warnings']) == 1
|
||||
|
||||
def test_get_active_profiles_info(self):
|
||||
"""Test active profiles info retrieval."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС', 'велосипед'])
|
||||
|
||||
profiles_info = scorer.get_active_profiles_info()
|
||||
|
||||
assert len(profiles_info) == 2
|
||||
assert profiles_info[0]['name'] == 'РАС'
|
||||
assert profiles_info[1]['name'] == 'велосипед'
|
||||
assert 'critical_warning' in profiles_info[0]
|
||||
assert 'critical_warning' in profiles_info[1]
|
||||
|
||||
|
||||
class TestWeightNormalization:
|
||||
"""Test weight normalization."""
|
||||
|
||||
def test_weights_normalized_after_modifiers(self):
|
||||
"""Test that weights sum to 1.0 after applying modifiers."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_age_modifiers(10)
|
||||
scorer.apply_season_modifiers('зима')
|
||||
scorer._normalize_weights()
|
||||
|
||||
total = sum(scorer.weights.values())
|
||||
assert total == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
def test_weights_normalized_after_profiles(self):
|
||||
"""Test that weights sum to 1.0 after applying profiles."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС'])
|
||||
scorer._normalize_weights()
|
||||
|
||||
total = sum(scorer.weights.values())
|
||||
assert total == pytest.approx(1.0, rel=0.01)
|
||||
|
||||
|
||||
class TestCriticalWarnings:
|
||||
"""Test critical warning system."""
|
||||
|
||||
def test_ras_critical_warning(self):
|
||||
"""Test РАС generates critical warning."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС'])
|
||||
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
warning = scorer.critical_warnings[0]
|
||||
assert warning['profile'] == 'РАС'
|
||||
assert 'водоёмы' in warning['warning']
|
||||
assert 'громкоговоритель' in warning['warning']
|
||||
|
||||
def test_epilepsy_critical_warning(self):
|
||||
"""Test эпилепсия generates critical warning."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['эпилепсия'])
|
||||
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
warning = scorer.critical_warnings[0]
|
||||
assert warning['profile'] == 'эпилепсия'
|
||||
assert 'Медицинский' in warning['warning']
|
||||
|
||||
def test_bicycle_critical_warning(self):
|
||||
"""Test велосипед generates critical warning."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['велосипед'])
|
||||
|
||||
assert len(scorer.critical_warnings) == 1
|
||||
warning = scorer.critical_warnings[0]
|
||||
assert warning['profile'] == 'велосипед'
|
||||
assert '10-15 км' in warning['warning']
|
||||
|
||||
def test_multiple_critical_warnings(self):
|
||||
"""Test multiple profiles generate multiple warnings."""
|
||||
scorer = WeightedScorer()
|
||||
scorer.apply_profile(['РАС', 'велосипед'])
|
||||
|
||||
assert len(scorer.critical_warnings) == 2
|
||||
Reference in New Issue
Block a user