"""B14: юнит-тесты чистого движка services/search_engine.py. claude_analyze мокается (движок тестируем изолированно, без httpx/ANTHROPIC). """ from __future__ import annotations from datetime import datetime from typing import Any import pytest from services.claude_service 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(): """Регресс из B14: bike 8yo 2h лес день → max_distance 5.4 км (Time 2 × НормС 1.2 × СП 0.5 × СУТ 0.9 × СУ-bike 5.0).""" model = await build_search_model(_base_input(), analyzer=_fake_analyzer()) assert model.max_distance_km == pytest.approx(5.4, abs=1e-6) assert model.coefficients['base_speed'] == 1.2 assert model.coefficients['terrain'] == 0.5 assert model.coefficients['urgency'] == 5.0 # bike 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) # Профили дошли и до claude (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(): """Пустые контейнеры payload не затирают карточку (старое поведение _as_case_data сохранено на уровне роутера — здесь фиксируем семантику SearchInput: явно переданные пустые списки допустимы).""" si = SearchInput(diagnosis_type=[]) assert si.to_case_data()['diagnosis_type'] == [] 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'