Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
566 lines
31 KiB
Python
566 lines
31 KiB
Python
"""Тесты 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
|