gendesign/backend/tests/services/site_finder/test_locations.py
Light1YT 379af88424
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m22s
CI / backend-tests (pull_request) Successful in 6m22s
fix(site_finder): make Location demand_index city-relative (#948)
demand_index used a fixed clamp01(velocity/50); on a live prod refresh
all 8 ЕКБ districts sold ≥50/mo so it saturated to 1.0 everywhere — zero
discrimination between districts. Redesign to mirror infra_index:
normalize each district's unit_velocity against the city reference (MAX
district velocity per refresh run), so demand always discriminates and
self-calibrates as the market grows (no magic constant to rot).

- normalize_demand(velocity, *, city_reference_velocity), pure + graceful
  (None stays None; reference<=0 -> honest 0.0, no ZeroDivisionError)
- refresh_locations now two-pass: collect velocities (one
  compute_market_metrics per district, no O(n^2)), derive city reference,
  normalize + upsert; SAVEPOINT-per-row and counters preserved
- remove _DEMAND_SATURATION_UPM constant; log city_reference_velocity
- tests: rewrite demand normalization + add end-to-end city-relative
  suite incl. discrimination regression guarding the all-1.0 prod bug

Refs #948
2026-06-08 13:58:12 +05:00

509 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты 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
# ── 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