5bdb345e33
B14 (поведение-сохраняющий рефакторинг): - services/search_engine.py — вся SAR-математика из analyze.py: SearchInput/SearchModel, build_search_model (чистая функция, без DB/auth/HTTP), деривации профилей/времени суток, unmodeled. - analyze.py — тонкая обёртка: сборка SearchInput + запись БД. - closed_cases.py — импорты хелперов из движка. - 12 юнит-тестов движка (claude_analyze мокается). - Контракт /analyze не изменён; регресс спеки подтверждён: bike 8yo 2h лес день -> 5.4 км. B15 (слои данных, Alembic): - backend/alembic (env из DATABASE_URL) + миграция 006_b15_layers. - Слой 4: search_models (case_id, version, input_snapshot, model_json). - Слой 3: search_teams, field_observations, areas_checked, found_events (geom JSONB GeoJSON, PostGIS в B16). - Слой 1: reference_priors (пустой, B12 заблокирован). - Бэкфилл: cases.analysis_log (объект с primary_zones) -> search_models v1; legacy-массивы и analysis_log-таблица не тронуты. - Проверено на CT108 в одноразовых pg16-контейнерах: чистая БД (без данных и с ними), бэкфилл=1 из 3 seed-кейсов, downgrade->upgrade идемпотентен, check-constraints работают, источник не модифицирован. pytest: 214 passed.
163 lines
6.4 KiB
Python
163 lines
6.4 KiB
Python
"""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' |