Import Vector lab project

This commit is contained in:
root
2026-06-06 18:31:55 +00:00
commit fcc22063ed
105 changed files with 31639 additions and 0 deletions
View File
+371
View File
@@ -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)
+341
View File
@@ -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)
+399
View File
@@ -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"
+294
View File
@@ -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']
+440
View File
@@ -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