"""B14: юнит-тесты чистого движка services/search_engine.py. Анализатор мокается (движок тестируем изолированно, без geo/httpx). """ from __future__ import annotations from datetime import datetime from typing import Any import pytest from services.rules_analysis import AnalysisResult, PrimaryZone from services.search_engine import ( SearchInput, build_search_model, derive_profiles, derive_time_of_day, unmodeled_profiles, ) def _fake_analyzer(urgency: str = 'высокая', radius: float = 1.5) -> Any: async def analyzer(case_data: dict[str, Any]) -> AnalysisResult: analyzer.captured = case_data return AnalysisResult( urgency=urgency, primary_zones=[ PrimaryZone(priority=1, name='Тест', direction='N', distance=0.5, reason='тест') ], search_radius_km=radius, key_locations=['водоёмы'], behavioral_prediction='тест', immediate_actions=['действие'], summary='тест', fallback_used=True, ) analyzer.captured = None return analyzer def _base_input(**overrides: Any) -> SearchInput: data = dict( age=8, gender='м', terrain=['лес'], elapsed_hours=2.0, has_transport='bike', time_of_day='день', ) data.update(overrides) return SearchInput(**data) async def test_build_search_model_bike_8yo_matches_old_contract(): """bike 8yo 2h лес день → max_distance. B1-ревью 2026-09-09 (Виктор): СП(лес) 0.5→1.0 (НормС — уже скорость смещения по среднему лесу; 0.5 учитывал лес дважды). Было 5.4 (2 × 1.2 × 0.5 × 0.9 × 5.0), стало 10.8 (2 × 1.2 × 1.0 × 0.9 × 5.0).""" model = await build_search_model(_base_input(), analyzer=_fake_analyzer()) assert model.max_distance_km == pytest.approx(10.8, abs=1e-6) assert model.coefficients['base_speed'] == 1.2 assert model.coefficients['terrain'] == 1.0 assert model.coefficients['urgency'] == 5.0 # bike async def test_teen_3h_forest_realistic_distance(): """Операционная проверка Виктора: здоровый подросток за 3 часа проходит заметно больше 2.5 км. При СП(лес)=1.0: 3 × 2.0 × 1.0 × 0.85 = 5.1 км.""" model = await build_search_model( _base_input(age=14, has_transport=None, elapsed_hours=3.0), analyzer=_fake_analyzer(), ) assert model.max_distance_km == pytest.approx(5.1, abs=1e-6) async def test_profiles_derived_from_diagnosis_and_transport(): analyzer = _fake_analyzer() model = await build_search_model( _base_input(diagnosis_type=['РАС'], cant_swim=True), analyzer=analyzer, ) assert 'РАС' in [p['name'] for p in model.active_profiles] assert model.critical_warnings == [] or isinstance(model.critical_warnings, list) # Профили дошли и до анализатора (case_data), и в скорер assert 'РАС' in analyzer.captured['profiles'] assert 'велосипед' in analyzer.captured['profiles'] assert 'не_умеет_плавать' in analyzer.captured['profiles'] async def test_ras_bike_profiles_case_sensitive_keys(): """Ключи профилей — русские (§8): РАС + велосипед, не bike.""" assert derive_profiles(['рас'], 'bike', False) == ['РАС', 'велосипед'] assert derive_profiles([], 'scooter', True) == ['самокат', 'не_умеет_плавать'] async def test_explicit_profiles_win_over_derivation(): assert derive_profiles(['РАС'], 'bike', True, explicit=['СДВГ']) == ['СДВГ'] async def test_unmodeled_diagnoses_flagged(): flagged = unmodeled_profiles(['ДЦП', 'РАС', 'слабый_слух']) assert [f['profile'] for f in flagged] == ['ДЦП', 'слабый_слух'] assert all(f['note'] for f in flagged) async def test_time_of_day_from_loss_time(): assert derive_time_of_day('2026-09-09T14:00:00') == 'день' assert derive_time_of_day('2026-09-09T20:00:00') == 'сумерки' assert derive_time_of_day('2026-09-09T02:00:00') == 'ночь' assert derive_time_of_day('мусор') == 'день' assert derive_time_of_day(None) == 'день' assert derive_time_of_day(datetime(2026, 9, 9, 23, 0)) == 'ночь' async def test_time_of_day_auto_applied_in_model(): model = await build_search_model( _base_input(time_of_day=None, loss_time='2026-09-09T21:00:00'), analyzer=_fake_analyzer(), ) assert model.time_of_day == 'сумерки' assert model.coefficients['time_of_day'] == 0.5 # суметки замедляют (видимость) async def test_psychotype_detected_and_applied(): answers = { 'unfamiliar_behavior': 'explore', 'stress_reaction': 'angry', 'leadership': 'always_leader', 'risk_taking': 'very', } analyzer = _fake_analyzer() model = await build_search_model( _base_input(psychotype_answers=answers), analyzer=analyzer, ) assert model.psychotype == 'dominant' assert model.psychotype_modifiers is not None assert model.psychotype_recommendations is not None assert analyzer.captured['psychotype_modifiers'] == model.psychotype_modifiers async def test_no_psychotype_when_no_answers(): model = await build_search_model(_base_input(), analyzer=_fake_analyzer()) assert model.psychotype is None assert model.psychotype_modifiers is None async def test_engine_does_not_touch_db_or_http(): """Чистота границы: движок не импортирует DB/auth/HTTP-клиентов.""" import services.search_engine as se source = open(se.__file__, encoding='utf-8').read() assert 'backend.database' not in source assert 'backend.routers' not in source assert 'SessionLocal' not in source assert 'Depends' not in source # И никакого ATAK/Meshtastic/CoT (критерий B20/B14) assert 'ATAK' not in source and 'Meshtastic' not in source and 'CoT' not in source async def test_case_id_merge_loses_payload_empty_containers(): """Пустые контейнеры выбрасываются из case_data (старое поведение _as_case_data): пустой diagnosis_type не доходит до сервисов.""" si = SearchInput(diagnosis_type=[]) assert 'diagnosis_type' not in si.to_case_data() async def test_none_fields_dropped_from_case_data(): """Регресс 2026-09-09: None-поля не должны попадать в case_data. Сервисы читают case_data.get(key, DEFAULT); ключ с None обманывает дефолт → elapsed_hours=None ломал calculate_max_distance (TypeError: float * NoneType) — «не удаётся запустить анализ». """ si = SearchInput(age=None, elapsed_hours=None, weather=None, gender=None) d = si.to_case_data() assert 'elapsed_hours' not in d assert 'age' not in d assert 'weather' not in d assert 'diagnosis_type' not in d # пустой список тоже выбрасывается # И полный прогон не падает: model = await build_search_model(si, analyzer=_fake_analyzer()) assert model.max_distance_km > 0 # elapsed None → дефолт 1.0 async def test_case_card_none_does_not_override_payload(): """Слияние карточки (case.to_detail) с payload: None из карточки не должен ломать расчёт при пустом payload.""" si = SearchInput(age=8, elapsed_hours=2.0, terrain=['лес'], has_transport='bike', time_of_day='день') d = si.to_case_data() assert d['elapsed_hours'] == 2.0 async def test_fallback_fields_flow_through(): analyzer = _fake_analyzer(urgency='критическая', radius=2.5) model = await build_search_model(_base_input(), analyzer=analyzer) assert model.urgency == 'критическая' assert model.search_radius_km == 2.5 assert model.fallback_used is True assert model.primary_zones[0]['direction'] == 'N'