From b29f9641898915c683e672e38514bcc83c858dc4 Mon Sep 17 00:00:00 2001 From: viktot14-ai Date: Wed, 9 Sep 2026 19:11:12 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20None-=D0=BF=D0=BE=D0=BB=D1=8F=20=D0=B2?= =?UTF-8?q?=20case=5Fdata=20=D0=BB=D0=BE=D0=BC=D0=B0=D0=BB=D0=B8=20/analyz?= =?UTF-8?q?e=20(float=20*=20NoneType)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Регресс B14: SearchInput.to_case_data() отдавал ключи с None (elapsed_hours/age/...), а сервисы читают case_data.get(key, DEFAULT) — ключ с None обманывает дефолт → TypeError в distance_service ('не удаётся запустить анализ' при запуске поиска со степ 1). Фикс: to_case_data возвращает старую семантику _as_case_data — None и пустые контейнеры выбрасываются. +2 регресс-теста. 192 passed. --- backend/tests/test_search_engine.py | 35 +++++++++++++++++++++++++---- services/search_engine.py | 13 +++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/backend/tests/test_search_engine.py b/backend/tests/test_search_engine.py index c37ef38..2996b3b 100644 --- a/backend/tests/test_search_engine.py +++ b/backend/tests/test_search_engine.py @@ -147,11 +147,38 @@ async def test_engine_does_not_touch_db_or_http(): async def test_case_id_merge_loses_payload_empty_containers(): - """Пустые контейнеры payload не затирают карточку (старое поведение - _as_case_data сохранено на уровне роутера — здесь фиксируем семантику - SearchInput: явно переданные пустые списки допустимы).""" + """Пустые контейнеры выбрасываются из case_data (старое поведение + _as_case_data): пустой diagnosis_type не доходит до сервисов.""" si = SearchInput(diagnosis_type=[]) - assert si.to_case_data()['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(): diff --git a/services/search_engine.py b/services/search_engine.py index 5807cd1..3b844a2 100644 --- a/services/search_engine.py +++ b/services/search_engine.py @@ -87,7 +87,12 @@ class SearchInput(BaseModel): def to_case_data(self) -> dict[str, Any]: """Плоский dict для сервисов (анализатор/скоринг) — тот же состав полей, - что раньше уходил из analyze_case.""" + что раньше уходил из analyze_case. + + None и пустые контейнеры выкидываются: сервисы читают через + case_data.get(key, DEFAULT), и ключ с None обманывает дефолт + (регресс 2026-09-09: elapsed_hours=None → TypeError в distance_service). + """ data: dict[str, Any] = { 'age': self.age, 'gender': self.gender, @@ -114,7 +119,11 @@ class SearchInput(BaseModel): data['terrain_primary'] = _first_terrain(self.terrain) if self.time_of_day: data['time_of_day'] = self.time_of_day - return data + # Семантика старого _as_case_data: None и пустые ([]/{}) не проходят + return { + k: v for k, v in data.items() + if v is not None and not (isinstance(v, (list, dict)) and len(v) == 0) + } class SearchModel(BaseModel):