Files
vector/backend/tests/test_scoring_service.py
T
2026-06-06 18:31:55 +00:00

441 lines
14 KiB
Python

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