f20080305d
Определение специфических рекомендаций (матрица профилей §8):
1. Ж/д слой (закрыт мёртвый railway ×2.5 у РАС):
- /api/v1/water/{case_id} отдаёт railway=rail как LineString
(без service/industrial/military веток), кэш общий v2;
- railway_warning «перекрыть/проверить немедленно» по профилям;
- SearchMap: Polyline слой ж/д (тёмно-красный), счётчики 💧/🚂.
2. cant_swim → профиль не_умеет_плавать (water ×3.0, без изменения
радиуса, critical_warning «обследовать водоёмы НЕМЕДЛЕННО»):
- раньше чекбокс влиял только на текст, в скоринге был пробел;
- derive в analyze._derive_profiles — работает и для closed_cases.
3. unmodeled_profiles: ДЦП/слабое зрение/слух — честная пометка
«вне поведенческой модели» с пояснением (vector_tasks B12:
профили без аналога не выдавать за учтённые); блок на фронте
в карточке здоровья.
Площадь воды: сферический эксцесс, проверен на квадрате 53° (744017 м²
vs 743272 точного). Тесты: 202 passed (новый test_cant_swim_profile).
454 lines
15 KiB
Python
454 lines
15 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_cant_swim_profile(self):
|
|
"""Test не_умеет_плавать profile — вода ×3.0 + медицинский приоритет."""
|
|
scorer = WeightedScorer()
|
|
base_water = scorer.weights['water']
|
|
scorer.apply_profile(['не_умеет_плавать'])
|
|
|
|
# Cant swim: water x3.0, no distance change
|
|
assert scorer.distance_multiplier == 1.0
|
|
assert scorer.weights['water'] == base_water * 3.0
|
|
assert 'не_умеет_плавать' in scorer.active_profiles
|
|
assert len(scorer.critical_warnings) == 1
|
|
assert 'НЕМЕДЛЕННО' in scorer.critical_warnings[0]['warning']
|
|
|
|
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
|