"""Тесты location-сервиса (#948 Part B, ТЗ §8.2). Покрывает: 1. Pure-нормализация каждого индекса → [0,1] + None-on-no-data (никогда 0-как-заглушка): normalize_competition / normalize_demand / normalize_infra. 2. compute_location_indices: мокаем 4 source-функции (compute_market_metrics / compute_future_supply_pressure / _district_poi_score / _city_avg_poi_score) → assert нормализованные значения + None когда источник вернул None/сбоил. 3. refresh_locations: idempotent upsert по district-источнику (мок _admin_names), SAVEPOINT per-row, счётчики; пустой источник → no-op. Стратегия mock: source-функции патчим через unittest.mock.patch, DB — MagicMock (сервис ходит в БД только через эти запатченные функции + upsert SQL, который на MagicMock-сессии ничего не делает). Детерминированно, без LLM, без реальной БД. """ from __future__ import annotations from types import SimpleNamespace from unittest.mock import MagicMock, patch from app.services.site_finder.locations import ( _DEMAND_SATURATION_UPM, compute_location_indices, normalize_competition, normalize_demand, normalize_infra, refresh_locations, ) # Пути патча — модуль locations импортирует функции по имени, патчим в его namespace. _MM = "app.services.site_finder.locations.compute_market_metrics" _FSP = "app.services.site_finder.locations.compute_future_supply_pressure" _DPOI = "app.services.site_finder.locations._district_poi_score" _CPOI = "app.services.site_finder.locations._city_avg_poi_score" _ADMIN = "app.services.site_finder.locations._admin_names" def _metrics( overstock_index: float | None, unit_velocity: float | None, ) -> SimpleNamespace: """Минимальный stand-in MarketMetrics — только поля, которые читает сервис. competition берётся из overstock_index (доступное/затоваренное конкурирующее предложение, ортогонально demand), demand — из unit_velocity. Это РАЗНЫЕ поля источника: тесты ниже опираются на их независимость. """ return SimpleNamespace(overstock_index=overstock_index, unit_velocity=unit_velocity) def _fsp(index: float | None) -> SimpleNamespace: """Минимальный stand-in FutureSupplyPressure — только .index.""" return SimpleNamespace(index=index) # ── 1. Pure-нормализация: competition ────────────────────────────────────────── class TestNormalizeCompetition: # Источник — overstock_index ∈ [0,1] (доля долго-непроданного конкурирующего # стока = доступное/затоваренное конкурирующее предложение). Уже нормирован, # только clamp. Выше = больше конкурентного давления для нового игрока. def test_mid_value_passthrough(self) -> None: # 0.6 затоваривания → competition 0.6 (НЕ делим на 100 — источник уже [0,1]). assert normalize_competition(0.6) == 0.6 def test_zero_is_zero_not_none(self) -> None: # 0 затоваривания — честный ноль (выборка была, сток весь свежий), НЕ None. assert normalize_competition(0.0) == 0.0 def test_full_is_one(self) -> None: # Весь доступный сток «завис» → максимум конкурентного давления. assert normalize_competition(1.0) == 1.0 def test_above_one_clamps(self) -> None: # Защита от артефактов > 1.0 → clamp в 1.0. assert normalize_competition(1.4) == 1.0 def test_none_passthrough(self) -> None: # Нет данных / нет доступных лотов → None (НЕ 0). assert normalize_competition(None) is None # ── 1. Pure-нормализация: demand (saturating) ────────────────────────────────── class TestNormalizeDemand: def test_half_saturation_is_half(self) -> None: assert normalize_demand(_DEMAND_SATURATION_UPM / 2) == 0.5 def test_at_saturation_is_one(self) -> None: assert normalize_demand(_DEMAND_SATURATION_UPM) == 1.0 def test_beyond_saturation_clamps_to_one(self) -> None: assert normalize_demand(_DEMAND_SATURATION_UPM * 3) == 1.0 def test_zero_velocity_is_zero_not_none(self) -> None: # 0 продаж/мес — честный ноль спроса (market_metrics уже отличил от None). assert normalize_demand(0.0) == 0.0 def test_none_passthrough(self) -> None: assert normalize_demand(None) is None def test_zero_saturation_degrades_without_crash(self) -> None: assert normalize_demand(5.0, saturation_upm=0.0) == 1.0 assert normalize_demand(0.0, saturation_upm=0.0) == 0.0 def test_monotonic_non_decreasing(self) -> None: prev = -1.0 for upm in range(0, 80, 5): cur = normalize_demand(float(upm)) assert cur is not None assert cur >= prev prev = cur # ── 1. Pure-нормализация: infra (district POI / city POI) ────────────────────── class TestNormalizeInfra: def test_equal_to_city_is_one(self) -> None: assert normalize_infra(10.0, 10.0) == 1.0 def test_half_of_city(self) -> None: assert normalize_infra(5.0, 10.0) == 0.5 def test_better_than_city_clamps_to_one(self) -> None: assert normalize_infra(25.0, 10.0) == 1.0 def test_district_none_is_none(self) -> None: # <3 ЖК с POI в районе → None (НЕ 0). assert normalize_infra(None, 10.0) is None def test_city_none_is_none(self) -> None: assert normalize_infra(5.0, None) is None def test_city_zero_is_none(self) -> None: # Деление на ноль не допускаем → None. assert normalize_infra(5.0, 0.0) is None # ── 2. compute_location_indices: reuse + normalize + null-on-no-data ──────────── class TestComputeLocationIndices: def test_all_indices_populated_and_normalized(self) -> None: db = MagicMock() with ( patch(_MM, return_value=_metrics(0.8, 25.0)), patch(_FSP, return_value=_fsp(0.42)), patch(_DPOI, return_value=12.0), patch(_CPOI, return_value=10.0), ): idx = compute_location_indices(db, "Кировский") assert idx.competition_index == 0.8 # overstock_index passthrough (уже 0..1) assert idx.demand_index == 0.5 # 25 / 50 saturation assert idx.future_supply_index == 0.42 # passthrough (уже 0..1) assert idx.infra_index == 1.0 # 12/10 clamp to 1.0 def test_competition_and_demand_from_distinct_sources(self) -> None: # ОРТОГОНАЛЬНОСТЬ: competition ← overstock_index, demand ← unit_velocity — # два РАЗНЫХ поля MarketMetrics. Подаём независимые значения и проверяем, что # competition отражает overstock (а НЕ velocity), а demand — velocity. Так # фиксируем, что индексы больше не из одного sales-velocity-сигнала # (старый sell_through был монотонен в продажах = коррелирован с demand). db = MagicMock() with ( # высокое затоваривание (много конкурирующего стока стоит) при НИЗКОЙ # скорости продаж — competition высокий, demand низкий (разъезжаются). patch(_MM, return_value=_metrics(0.9, 5.0)), patch(_FSP, return_value=_fsp(None)), patch(_DPOI, return_value=None), patch(_CPOI, return_value=10.0), ): idx_high_comp = compute_location_indices(db, "Кировский") with ( # мало затоваривания (сток быстро уходит) при ВЫСОКОЙ скорости — # competition низкий, demand высокий (инверсия первого кейса). patch(_MM, return_value=_metrics(0.1, 45.0)), patch(_FSP, return_value=_fsp(None)), patch(_DPOI, return_value=None), patch(_CPOI, return_value=10.0), ): idx_low_comp = compute_location_indices(db, "Кировский") # competition следует за overstock, НЕ за velocity. assert idx_high_comp.competition_index == 0.9 assert idx_low_comp.competition_index == 0.1 # demand следует за velocity независимо от competition. assert idx_high_comp.demand_index == 0.1 # 5 / 50 assert idx_low_comp.demand_index == 0.9 # 45 / 50 # Главное: high-competition-кейс имеет НИЗКИЙ demand, low-competition — ВЫСОКИЙ. # Будь competition прежним sell_through (∝ продажам), он бы рос ВМЕСТЕ с demand; # здесь они расходятся → сигналы ортогональны. assert idx_high_comp.competition_index > idx_low_comp.competition_index assert idx_high_comp.demand_index < idx_low_comp.demand_index def test_no_market_data_yields_none_competition_and_demand(self) -> None: db = MagicMock() with ( patch(_MM, return_value=_metrics(None, None)), patch(_FSP, return_value=_fsp(None)), patch(_DPOI, return_value=None), patch(_CPOI, return_value=10.0), ): idx = compute_location_indices(db, "Кировский") # Все None (нет данных) — НИ ОДИН не подменён 0. assert idx.competition_index is None assert idx.demand_index is None assert idx.future_supply_index is None assert idx.infra_index is None def test_source_exception_isolated_to_its_index(self) -> None: # market_metrics бросает → competition/demand None, но future_supply + infra # всё равно считаются (graceful per-signal, не валит весь расчёт). db = MagicMock() with ( patch(_MM, side_effect=RuntimeError("db boom")), patch(_FSP, return_value=_fsp(0.3)), patch(_DPOI, return_value=8.0), patch(_CPOI, return_value=10.0), ): idx = compute_location_indices(db, "Ленинский") assert idx.competition_index is None assert idx.demand_index is None assert idx.future_supply_index == 0.3 assert idx.infra_index == 0.8 def test_indices_within_unit_range(self) -> None: db = MagicMock() with ( patch(_MM, return_value=_metrics(0.55, 13.0)), patch(_FSP, return_value=_fsp(0.7)), patch(_DPOI, return_value=9.0), patch(_CPOI, return_value=10.0), ): idx = compute_location_indices(db, "Академический") for v in ( idx.infra_index, idx.competition_index, idx.demand_index, idx.future_supply_index, ): assert v is not None assert 0.0 <= v <= 1.0 # ── 3. refresh_locations: idempotent upsert + SAVEPOINT + counters ────────────── def _refresh_db() -> MagicMock: """MagicMock-сессия с рабочим begin_nested() context manager (SAVEPOINT).""" db = MagicMock() db.begin_nested.return_value.__enter__ = MagicMock() db.begin_nested.return_value.__exit__ = MagicMock(return_value=False) return db class TestRefreshLocations: def test_upserts_every_district(self) -> None: db = _refresh_db() districts = {"Кировский", "Ленинский", "Академический"} with ( patch(_ADMIN, return_value=districts), patch( "app.services.site_finder.locations.compute_location_indices", return_value=SimpleNamespace( infra_index=0.5, competition_index=0.6, demand_index=0.4, future_supply_index=0.3, ), ), ): result = refresh_locations(db) assert result == {"districts": 3, "upserted": 3, "failed": 0} # SAVEPOINT использован per-row (3 раза), один commit в конце. assert db.begin_nested.call_count == 3 assert db.execute.call_count == 3 db.commit.assert_called_once() def test_idempotent_second_run_same_counts(self) -> None: # Идемпотентность: повторный прогон с тем же источником → те же счётчики # (ON CONFLICT обновляет, не плодит). На уровне сервиса это детерминизм # числа upsert'ов при неизменном district-источнике. districts = {"Кировский", "Ленинский"} ns = SimpleNamespace( infra_index=0.5, competition_index=0.6, demand_index=0.4, future_supply_index=0.3 ) with ( patch(_ADMIN, return_value=districts), patch( "app.services.site_finder.locations.compute_location_indices", return_value=ns ), ): first = refresh_locations(_refresh_db()) second = refresh_locations(_refresh_db()) assert first == second == {"districts": 2, "upserted": 2, "failed": 0} def test_failed_row_isolated_counts_as_failed(self) -> None: # Один район бросает на upsert → failed=1, остальные upsert'ятся (SAVEPOINT). db = _refresh_db() db.execute.side_effect = [RuntimeError("row boom"), None, None] districts_sorted = ["Академический", "Кировский", "Ленинский"] # sorted() в сервисе with ( patch(_ADMIN, return_value=set(districts_sorted)), patch( "app.services.site_finder.locations.compute_location_indices", return_value=SimpleNamespace( infra_index=0.5, competition_index=0.6, demand_index=0.4, future_supply_index=0.3, ), ), ): result = refresh_locations(db) assert result == {"districts": 3, "upserted": 2, "failed": 1} db.commit.assert_called_once() def test_empty_district_source_is_noop(self) -> None: db = _refresh_db() with patch(_ADMIN, return_value=set()): result = refresh_locations(db) assert result == {"districts": 0, "upserted": 0, "failed": 0} db.execute.assert_not_called() db.commit.assert_not_called()