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
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).
327 lines
16 KiB
Python
327 lines
16 KiB
Python
"""Тесты 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()
|