""" 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