gendesign/backend/tests/services/site_finder/test_locations.py
Light1YT 8da1c00138
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
Deploy / deploy (push) Successful in 1m11s
CI / backend-tests (push) Successful in 6m27s
CI / backend-tests (pull_request) Successful in 6m22s
feat(location): district-level Location entity + indices (#948 part B)
Promote district to a first-class `location` entity (ТЗ §8.2), ADDITIVE — no
district->FK refactor. New `location` table keyed by district_name (joinable by
string), carrying 4 normalized [0,1] indices (NULL when no data, never 0):
- infra: _district_poi_score / _city_avg_poi_score (per-district POI aggregate)
- competition: market_metrics.overstock_index (available/stuck competing supply
  — orthogonal to demand; NOT sell-through, which is market-heat correlated w/ demand)
- demand: market_metrics.unit_velocity (saturating /50)
- future_supply: future_supply_pressure.index (passthrough, already 0..1)

- data/sql/146_location.sql: idempotent table + UNIQUE(district_name) + range
  CHECK + centroid GIST
- services/site_finder/locations.py: compute_location_indices (reuses forecast
  per-district fns) + refresh_locations (SAVEPOINT per-row, CAST, ON CONFLICT)
- workers/tasks/location_refresh.py + beat (Mon 07:00 MSK, after supply-layers)
- api/v1/locations.py: read-only GET list + GET by name (analyst+admin via rbac,
  frontend pilot-gated)
- tests: 34 (normalization 0..1/null, competition⊥demand orthogonality, idempotent
  upsert, read API list/by-name/404)

Part of #948 (Part A insight shipped #1164).
2026-06-08 13:28:19 +05:00

327 lines
16 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.
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()