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