"""Тесты location-сервиса (#948 Part B, ТЗ §8.2). Покрывает: 1. Pure-нормализация каждого индекса → [0,1] + None-on-no-data (никогда 0-как-заглушка): normalize_competition / normalize_demand / normalize_infra. demand — CITY-RELATIVE (#948 fix): нормировка против опорной скорости города, а НЕ фикс-константы. 2. compute_location_indices: мокаем 4 source-функции (compute_market_metrics / compute_future_supply_pressure / _district_poi_score / _city_avg_poi_score) → assert нормализованные значения + None когда источник вернул None/сбоил. demand здесь НЕ считается (pass 2 refresh) — проверяем raw_unit_velocity + demand=None. 3. refresh_locations: two-pass (city-relative demand), idempotent upsert по district-источнику (мок _admin_names), SAVEPOINT per-row, счётчики; пустой источник → no-op. Включая discrimination-регрессию против прод-бага (#948: demand=1.0 у всех 8 районов ЕКБ при старом /50-clamp). Стратегия mock: source-функции патчим через unittest.mock.patch, DB — MagicMock (сервис ходит в БД только через эти запатченные функции + upsert SQL, который на MagicMock-сессии ничего не делает). Детерминированно, без LLM, без реальной БД. """ from __future__ import annotations from itertools import pairwise from types import SimpleNamespace from unittest.mock import MagicMock, patch from app.services.site_finder.locations import ( LocationIndices, 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 (CITY-RELATIVE, #948 fix) ───────────────────── class TestNormalizeDemand: """demand нормируется ОТНОСИТЕЛЬНО опорной скорости города (как infra), а НЕ делением на фикс-константу. velocity / city_reference, clamp [0,1]. Зеркалит normalize_infra. #948 fix: старый /50-clamp давал demand=1.0 у ВСЕХ 8 районов ЕКБ (≥50/мес каждый) → ноль дискриминации. City-relative само-калибруется и всегда даёт спред. """ def test_relative_to_reference_half(self) -> None: # Район вдвое медленнее самого горячего → 0.5 (velocity/reference, как infra). assert normalize_demand(50.0, city_reference_velocity=100.0) == 0.5 def test_at_reference_is_one(self) -> None: # Сам горячий район (velocity == опора) → 1.0. assert normalize_demand(100.0, city_reference_velocity=100.0) == 1.0 def test_above_reference_clamps_to_one(self) -> None: # velocity > reference (плавающая арифметика / артефакт) → clamp 1.0. assert normalize_demand(130.0, city_reference_velocity=100.0) == 1.0 # ── Hard correctness edge case #4: velocity 0 при положительной опоре → 0.0 ── def test_zero_velocity_with_positive_reference_is_zero_not_none(self) -> None: # 0 продаж/мес при живом рынке — честный ноль спроса (НЕ None: market_metrics # уже отличил «0 продаж» от «нет выборки»). assert normalize_demand(0.0, city_reference_velocity=100.0) == 0.0 # ── Hard correctness edge case #1 (pure-уровень): velocity None → None ────── def test_none_velocity_passthrough(self) -> None: # «Нет выборки» → None, НИКОГДА не подменяем 0 (load-bearing дисциплина модуля). assert normalize_demand(None, city_reference_velocity=100.0) is None def test_none_reference_is_none(self) -> None: # Нет опоры (ни у одного района нет данных) → None у каждого. assert normalize_demand(42.0, city_reference_velocity=None) is None # ── Hard correctness edge case #3: reference == 0 → 0.0 (НЕ ZeroDivisionError) ─ def test_zero_reference_is_zero_not_none_no_crash(self) -> None: # Вырождение: весь город честно продал 0/мес → опора 0. Честный нулевой спрос # у всех (НЕ None), без деления на ноль. assert normalize_demand(0.0, city_reference_velocity=0.0) == 0.0 # И для (гипотетического) положительного velocity при нулевой опоре — без падения. assert normalize_demand(5.0, city_reference_velocity=0.0) == 0.0 def test_monotonic_non_decreasing_in_velocity(self) -> None: # При фиксированной опоре индекс монотонно растёт со скоростью района. prev = -1.0 for upm in range(0, 110, 10): cur = normalize_demand(float(upm), city_reference_velocity=100.0) 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) # demand НЕ считается здесь (city-relative, pass 2 refresh) — None-плейсхолдер, # а сырая скорость собрана в raw_unit_velocity для последующей нормировки. assert idx.demand_index is None assert idx.raw_unit_velocity == 25.0 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_velocity_from_distinct_sources(self) -> None: # ОРТОГОНАЛЬНОСТЬ: competition ← overstock_index, demand-сырьё ← unit_velocity — # два РАЗНЫХ поля MarketMetrics. Подаём независимые значения и проверяем, что # competition отражает overstock (а НЕ velocity), а raw_unit_velocity — velocity. # Так фиксируем, что сигналы не из одного sales-velocity-источника (старый # sell_through был монотонен в продажах = коррелирован с demand). demand_index # здесь None (city-relative нормировка — pass 2), сравниваем сырьё. db = MagicMock() with ( # высокое затоваривание (много конкурирующего стока стоит) при НИЗКОЙ # скорости продаж — competition высокий, velocity низкая (разъезжаются). 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 низкий, velocity высокая (инверсия первого кейса). 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 считается позже (pass 2) — здесь оба None, а сырьё следует за velocity. assert idx_high_comp.demand_index is None assert idx_low_comp.demand_index is None assert idx_high_comp.raw_unit_velocity == 5.0 assert idx_low_comp.raw_unit_velocity == 45.0 # Главное: high-competition-кейс имеет НИЗКУЮ скорость, low-competition — ВЫСОКУЮ. # Будь competition прежним sell_through (∝ продажам), он бы рос ВМЕСТЕ со скоростью; # здесь они расходятся → сигналы ортогональны. assert idx_high_comp.competition_index > idx_low_comp.competition_index assert idx_high_comp.raw_unit_velocity < idx_low_comp.raw_unit_velocity 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. raw_unit_velocity тоже None # (нет выборки) → район получит demand=None в pass 2. assert idx.competition_index is None assert idx.demand_index is None assert idx.raw_unit_velocity 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 None + raw_unit_velocity None (→ demand # None в pass 2), но 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.raw_unit_velocity 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, "Академический") # demand_index здесь None (city-relative, считается в pass 2) — проверяем 3 # locally-нормированных индекса в [0,1] + что сырьё собрано. for v in ( idx.infra_index, idx.competition_index, idx.future_supply_index, ): assert v is not None assert 0.0 <= v <= 1.0 assert idx.demand_index is None assert idx.raw_unit_velocity == 13.0 # ── 2b. infra/POI swallow site: session-poisoning regression (#2464 cluster A) ── # _district_poi_score/_city_avg_poi_score (analytics_queries.py) do db.execute with no # try/except of their own; compute_location_indices' bare `except Exception` used to # swallow a failure WITHOUT rolling back. On real Postgres that leaves the transaction # "aborted", poisoning every later db.execute on the SAME session — here that's the # REMAINING districts in refresh_locations' pass-1 loop (all on one Session). # # Fix = plain db.rollback() (NOT begin_nested SAVEPOINT): compute_location_indices' # only caller is refresh_locations, whose only caller (location_refresh.py Celery task) # opens its OWN SessionLocal() — an owned, not request/report-shared, session. At this # point in pass 1 no writes are pending yet (all UPSERTs happen later in pass 2), so # rollback() has nothing to lose and doesn't orphan an outer SessionTransaction (unlike # velocity.py:170). This is DIFFERENT from the pass-2 upsert loop below, which MUST use # begin_nested (SAVEPOINT) — a plain rollback there would revert already-upserted rows # from earlier districts in the same uncommitted transaction. class TestInfraPoiRollbackRegression: def test_infra_failure_rolls_back_and_falls_back_to_none(self) -> None: db = MagicMock() with ( patch(_MM, return_value=_metrics(0.5, 20.0)), patch(_FSP, return_value=_fsp(0.4)), patch(_DPOI, side_effect=RuntimeError("current transaction is aborted")), patch(_CPOI, return_value=10.0), ): idx = compute_location_indices(db, "Кировский") # documented fallback: infra_index None, остальные индексы (isolated per-block) # всё равно посчитаны. assert idx.infra_index is None assert idx.competition_index == 0.5 assert idx.future_supply_index == 0.4 db.rollback.assert_called_once() # Pass-1 (read-only) — begin_nested здесь НЕ нужен (тот используется только # в pass-2 upsert loop, где rollback() потерял бы предыдущие районы). db.begin_nested.assert_not_called() def test_session_usable_for_next_district_after_infra_failure(self) -> None: # Симулирует refresh_locations pass 1: район 1 сбоит на infra, район 2 идёт # следом на ТОЙ ЖЕ session. Без rollback (до фикса) реальный Postgres отдал бы # "current transaction is aborted" на district 2 (session-poisoning, #2464). db = MagicMock() calls = {"n": 0} def _dpoi_side_effect(_db: object, *, district_name: str) -> float: calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("db boom on district 1") return 8.0 with ( patch(_MM, return_value=_metrics(0.5, 20.0)), patch(_FSP, return_value=_fsp(0.4)), patch(_DPOI, side_effect=_dpoi_side_effect), patch(_CPOI, return_value=10.0), ): idx1 = compute_location_indices(db, "Кировский") idx2 = compute_location_indices(db, "Ленинский") assert idx1.infra_index is None db.rollback.assert_called_once() # Второй район на той же сессии успешно считает infra (8/10 = 0.8) — session # осталась usable после rollback (не отравлена). assert idx2.infra_index == 0.8 # ── 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=LocationIndices( infra_index=0.5, competition_index=0.6, demand_index=None, # city-relative, заполнит pass 2 future_supply_index=0.3, raw_unit_velocity=30.0, ), ), ): result = refresh_locations(db) assert result == {"districts": 3, "upserted": 3, "failed": 0} # SAVEPOINT использован per-row (3 раза в pass 2), один 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 = LocationIndices( infra_index=0.5, competition_index=0.6, demand_index=None, future_supply_index=0.3, raw_unit_velocity=30.0, ) 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=LocationIndices( infra_index=0.5, competition_index=0.6, demand_index=None, future_supply_index=0.3, raw_unit_velocity=30.0, ), ), ): 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() # ── 4. refresh_locations city-relative demand wiring (#948 fix, END-TO-END) ───── # Прогоняем НАСТОЯЩИЙ two-pass demand-путь (compute_location_indices НЕ мокаем — # мокаем 4 источника) и захватываем, что реально апсертится в demand_index по району. # Здесь живут hard-correctness edge cases #1,#2,#5 + discrimination-регрессия #6. def _capturing_refresh_db() -> tuple[MagicMock, dict[str, dict[str, object]]]: """MagicMock-сессия, захватывающая params каждого upsert по district_name. Возвращает (db, captured) где captured[district_name] = переданный в execute dict (включая итоговый demand_index). begin_nested работает как no-op SAVEPOINT. """ captured: dict[str, dict[str, object]] = {} def _record(_sql: object, params: dict[str, object]) -> None: captured[str(params["district_name"])] = params db = MagicMock() db.begin_nested.return_value.__enter__ = MagicMock() db.begin_nested.return_value.__exit__ = MagicMock(return_value=False) db.execute.side_effect = _record return db, captured def _velocity_by_district(mapping: dict[str, float | None]) -> object: """side_effect для compute_market_metrics: отдаёт per-district unit_velocity. overstock_index фиксируем (competition тут не предмет теста). Ключ — kwarg district=. """ def _side_effect(_db: object, *, district: str, window_months: int) -> SimpleNamespace: return _metrics(overstock_index=0.5, unit_velocity=mapping[district]) return _side_effect class TestRefreshDemandCityRelative: def _run(self, velocities: dict[str, float | None]) -> dict[str, dict[str, object]]: """Прогнать refresh с заданными per-district velocities, вернуть captured upserts.""" db, captured = _capturing_refresh_db() with ( patch(_ADMIN, return_value=set(velocities)), patch(_MM, side_effect=_velocity_by_district(velocities)), patch(_FSP, return_value=_fsp(None)), patch(_DPOI, return_value=None), patch(_CPOI, return_value=10.0), ): result = refresh_locations(db) assert result["upserted"] == len(velocities) assert result["failed"] == 0 return captured # ── Hard correctness edge case #6 — THE critical discrimination regression ── def test_discriminates_realistic_spread_regression_948(self) -> None: # РЕГРЕССИЯ #948: на live-прогоне старый demand=clamp01(velocity/50) выдал # demand=1.0 у ВСЕХ 8 районов ЕКБ (Академический, Верх-Исетский, # Железнодорожный, Кировский, Ленинский, Октябрьский, Орджоникидзевский, # Чкаловский) — каждый продаёт ≥50/мес → плоское насыщение, НОЛЬ дискриминации. # City-relative нормировка ОБЯЗАНА разнести их в distinct, monotonic спред. velocities = { "Академический": 60.0, "Верх-Исетский": 120.0, "Железнодорожный": 180.0, "Кировский": 300.0, "Чкаловский": 450.0, } captured = self._run(velocities) # Извлекаем как float (в этом populated-кейсе ни один не None — заодно проверяем). demands: dict[str, float] = {} for d in velocities: v = captured[d]["demand_index"] assert isinstance(v, float) demands[d] = v # Самый горячий (450) → ровно 1.0; остальные строго ниже. assert demands["Чкаловский"] == 1.0 # Все значения РАЗЛИЧНЫ (старый /50 дал бы пять раз 1.0). vals = list(demands.values()) assert len(set(vals)) == len(vals), f"demand не дискриминирует: {demands}" # Строго возрастает по скорости (60<120<180<300<450). ordered = [ demands["Академический"], demands["Верх-Исетский"], demands["Железнодорожный"], demands["Кировский"], demands["Чкаловский"], ] assert ordered == sorted(ordered) # Попарно строго возрастает (каждый следующий район горячее предыдущего). assert all(a < b for a, b in pairwise(ordered)) # Осмысленный диапазон: min заметно мал (<0.2: 60/450≈0.133), max == 1.0. assert min(vals) < 0.2 assert max(vals) == 1.0 # ── Hard correctness edge case #1: ВСЕ velocities None → ВСЕ demand None ──── def test_all_velocities_none_yields_all_demand_none(self) -> None: captured = self._run({"Кировский": None, "Ленинский": None, "Академический": None}) # Нет опоры → demand None у всех. НИКОГДА не подменяем 0. for d in ("Кировский", "Ленинский", "Академический"): assert captured[d]["demand_index"] is None # ── Hard correctness edge case #2: смешанно — None только у безданных ─────── def test_mixed_none_and_present(self) -> None: # Опора = MAX среди НЕ-None (= 200). Район без данных → demand None; районы с # данными нормируются относительно 200, без учёта None в опоре. captured = self._run({"Кировский": 200.0, "Ленинский": 50.0, "Академический": None}) assert captured["Академический"]["demand_index"] is None # нет выборки assert captured["Кировский"]["demand_index"] == 1.0 # сам горячий (=опора) assert captured["Ленинский"]["demand_index"] == 0.25 # 50 / 200 # ── Hard correctness edge case #3 (end-to-end): опора 0 → demand 0.0 у всех ─ def test_all_zero_velocity_yields_honest_zero_not_none(self) -> None: # Все районы честно продали 0/мес → опора 0. Честный нулевой спрос (0.0), НЕ # None и без ZeroDivisionError. captured = self._run({"Кировский": 0.0, "Ленинский": 0.0}) for d in ("Кировский", "Ленинский"): assert captured[d]["demand_index"] == 0.0 # ── Hard correctness edge case #5: единственный район / все равны → 1.0 ───── def test_single_district_is_one(self) -> None: captured = self._run({"Кировский": 137.0}) assert captured["Кировский"]["demand_index"] == 1.0 def test_all_equal_velocities_all_one(self) -> None: # Все одинаково горячи → опора = их общее значение → 1.0 у каждого. captured = self._run({"Кировский": 90.0, "Ленинский": 90.0, "Академический": 90.0}) for d in ("Кировский", "Ленинский", "Академический"): assert captured[d]["demand_index"] == 1.0