277 lines
11 KiB
Python
277 lines
11 KiB
Python
"""Tests for POI saturation per capita (#42).
|
||
|
||
Pure-функции (severity / multiplier / provision_ratio) — без БД.
|
||
DB-слой compute_district_saturation — через минимальный mock Session
|
||
(тот же приём, что в test_poi_score.py).
|
||
"""
|
||
|
||
from contextlib import contextmanager
|
||
from datetime import date
|
||
|
||
import pytest
|
||
|
||
from app.services.site_finder.saturation import (
|
||
_MULT_DEFICIT,
|
||
_MULT_NEUTRAL,
|
||
_MULT_SURPLUS,
|
||
classify_severity,
|
||
compute_district_saturation,
|
||
compute_provision_ratio,
|
||
saturation_multiplier,
|
||
)
|
||
|
||
# ── classify_severity ─────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_severity_none_is_none():
|
||
"""Нет данных (None) → None, НЕ «острый дефицит»."""
|
||
assert classify_severity(None) is None
|
||
|
||
|
||
def test_severity_buckets():
|
||
assert classify_severity(0.2) == "острый дефицит"
|
||
assert classify_severity(0.49) == "острый дефицит"
|
||
assert classify_severity(0.5) == "дефицит"
|
||
assert classify_severity(0.84) == "дефицит"
|
||
assert classify_severity(0.85) == "норма"
|
||
assert classify_severity(1.0) == "норма"
|
||
assert classify_severity(1.3) == "норма"
|
||
assert classify_severity(1.31) == "профицит"
|
||
assert classify_severity(3.0) == "профицит"
|
||
|
||
|
||
# ── saturation_multiplier (acceptance #42) ─────────────────────────────────────
|
||
|
||
|
||
def test_multiplier_deficit_boosts():
|
||
"""Дефицитный район → ×1.2 (incentive строить school-adjacent)."""
|
||
assert saturation_multiplier("school", 0.6) == _MULT_DEFICIT
|
||
|
||
|
||
def test_multiplier_surplus_penalises():
|
||
"""Перенасыщенный район → ×0.5."""
|
||
assert saturation_multiplier("school", 2.0) == _MULT_SURPLUS
|
||
|
||
|
||
def test_multiplier_norm_neutral():
|
||
assert saturation_multiplier("school", 1.0) == _MULT_NEUTRAL
|
||
|
||
|
||
def test_multiplier_none_and_unknown_category_neutral():
|
||
"""Нет данных или категория без норматива → нейтрально (×1.0)."""
|
||
assert saturation_multiplier("school", None) == _MULT_NEUTRAL
|
||
assert saturation_multiplier("metro_stop", 0.1) == _MULT_NEUTRAL
|
||
|
||
|
||
# ── compute_provision_ratio ─────────────────────────────────────────────────────
|
||
|
||
|
||
def test_provision_unknown_category_none():
|
||
assert (
|
||
compute_provision_ratio(poi_count=5, population=100000, age_share=0.1, category="park")
|
||
is None
|
||
)
|
||
|
||
|
||
def test_provision_zero_population_none():
|
||
assert (
|
||
compute_provision_ratio(poi_count=5, population=0, age_share=0.1, category="school") is None
|
||
)
|
||
|
||
|
||
def test_provision_missing_age_share_none():
|
||
"""Школа/детсад без age_share → None (когорту не оценить)."""
|
||
assert (
|
||
compute_provision_ratio(poi_count=5, population=100000, age_share=None, category="school")
|
||
is None
|
||
)
|
||
|
||
|
||
def test_provision_hospital_ignores_age_share():
|
||
"""Поликлиника нормируется на ВСЁ население — age_share не нужен."""
|
||
# 100000 жителей, норматив 0.1 учр./1000 = 10 учреждений эталон.
|
||
# 10 объектов × capacity 1.0 / (100000/1000) = 10/100 = 0.1 на 1000 = ровно норматив.
|
||
ratio = compute_provision_ratio(
|
||
poi_count=10, population=100000, age_share=None, category="hospital"
|
||
)
|
||
assert ratio == pytest.approx(1.0)
|
||
|
||
|
||
def test_provision_more_poi_higher_ratio():
|
||
"""Больше объектов при той же когорте → выше обеспеченность (монотонность)."""
|
||
few = compute_provision_ratio(
|
||
poi_count=2, population=100000, age_share=0.115, category="school"
|
||
)
|
||
many = compute_provision_ratio(
|
||
poi_count=10, population=100000, age_share=0.115, category="school"
|
||
)
|
||
assert few is not None and many is not None
|
||
assert many > few
|
||
|
||
|
||
def test_provision_smaller_cohort_higher_ratio():
|
||
"""#42 суть: та же школа на МЕНЬШЕЕ число детей → выше обеспеченность per capita."""
|
||
big_cohort = compute_provision_ratio(
|
||
poi_count=3, population=300000, age_share=0.115, category="school"
|
||
)
|
||
small_cohort = compute_provision_ratio(
|
||
poi_count=3, population=100000, age_share=0.115, category="school"
|
||
)
|
||
assert big_cohort is not None and small_cohort is not None
|
||
assert small_cohort > big_cohort
|
||
|
||
|
||
# ── compute_district_saturation (mock DB) ────────────────────────────────────
|
||
|
||
|
||
class _MockMappings:
|
||
def __init__(self, row: dict | None) -> None:
|
||
self._row = row
|
||
|
||
def first(self) -> dict | None:
|
||
return self._row
|
||
|
||
|
||
class _MockResult:
|
||
def __init__(self, row: dict | None) -> None:
|
||
self._row = row
|
||
|
||
def mappings(self) -> "_MockMappings":
|
||
return _MockMappings(self._row)
|
||
|
||
|
||
class _MockDb:
|
||
"""Минимальный мок SQLAlchemy Session (как в test_poi_score.py).
|
||
|
||
``begin_nested`` — реальный (не-swallow) context manager, зеркалит SAVEPOINT из
|
||
``compute_district_saturation`` (#2464 cluster A finding 4): исключение внутри
|
||
``with`` должно долетать до ``except`` в проде, не глушиться на выходе из CM.
|
||
"""
|
||
|
||
def __init__(self, row: dict | None, *, raise_on_execute: bool = False) -> None:
|
||
self._row = row
|
||
self._raise = raise_on_execute
|
||
|
||
@contextmanager
|
||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||
yield
|
||
|
||
def execute(self, *_args: object, **_kwargs: object) -> _MockResult:
|
||
if self._raise:
|
||
raise RuntimeError("simulated DB failure")
|
||
return _MockResult(self._row)
|
||
|
||
|
||
def _chkalovsky_row(n_school: int = 12) -> dict:
|
||
"""Чкаловский — крупнейший район ЕКБ (286277 чел.), типичный дефицит школ."""
|
||
return {
|
||
"population": 286277,
|
||
"area_km2": 389.81,
|
||
"age_share_preschool": 0.08,
|
||
"age_share_school": 0.115,
|
||
"age_share_elderly": 0.16,
|
||
"age_cohorts_estimated": True,
|
||
"source": "Росстат 2025-01-01",
|
||
"as_of_date": date(2025, 1, 1),
|
||
"n_school": n_school,
|
||
"n_kindergarten": 20,
|
||
"n_hospital": 5,
|
||
}
|
||
|
||
|
||
def test_saturation_no_demographics_returns_none():
|
||
"""Район без строки демографии (population NULL) → None."""
|
||
assert compute_district_saturation(_MockDb(None), "Неизвестный") is None
|
||
|
||
|
||
def test_saturation_db_error_returns_none():
|
||
"""Ошибка БД не роняет analyze — graceful None."""
|
||
db = _MockDb(_chkalovsky_row(), raise_on_execute=True)
|
||
assert compute_district_saturation(db, "Чкаловский") is None
|
||
|
||
|
||
def test_saturation_db_error_leaves_session_usable_for_next_query():
|
||
"""#2464 cluster A finding 4: сбой query здесь не должен отравлять session.
|
||
|
||
Симулируем реальный сценарий: db.execute кидает ОДИН раз (внутри
|
||
compute_district_saturation), затем на ТОЙ ЖЕ session успешно отрабатывает
|
||
следующий (не связанный) запрос — как это было бы с persist_analysis_run
|
||
сразу после saturation-блока в parcels.py analyze_parcel. Без SAVEPOINT
|
||
(begin_nested) второй execute на реальном Postgres упал бы с "current
|
||
transaction is aborted, commands ignored until end of transaction block".
|
||
"""
|
||
|
||
class _FlakyDb:
|
||
def __init__(self) -> None:
|
||
self.calls = 0
|
||
|
||
@contextmanager
|
||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||
yield
|
||
|
||
def execute(self, *_args: object, **_kwargs: object) -> _MockResult:
|
||
self.calls += 1
|
||
if self.calls == 1:
|
||
raise RuntimeError("simulated DB failure")
|
||
return _MockResult({"marker": "next-query-succeeded"})
|
||
|
||
db = _FlakyDb()
|
||
assert compute_district_saturation(db, "Чкаловский") is None
|
||
# Сессия осталась usable: следующий (не связанный) db.execute отрабатывает.
|
||
result = db.execute("SELECT 1")
|
||
assert result.mappings().first() == {"marker": "next-query-succeeded"}
|
||
assert db.calls == 2
|
||
|
||
|
||
def test_saturation_shape_and_flags():
|
||
db = _MockDb(_chkalovsky_row())
|
||
out = compute_district_saturation(db, "Чкаловский")
|
||
assert out is not None
|
||
assert out["district"] == "Чкаловский"
|
||
assert out["population"] == 286277
|
||
assert out["as_of_date"] == "2025-01-01"
|
||
assert out["cohorts_estimated"] is True
|
||
assert set(out["categories"]) == {"school", "kindergarten", "hospital"}
|
||
school = out["categories"]["school"]
|
||
# Школьная когорта — оценка (региональная доля), помечена флагом.
|
||
assert school["cohort_estimated"] is True
|
||
assert school["cohort_population"] == int(286277 * 0.115)
|
||
assert school["poi_count"] == 12
|
||
assert school["norm"]["capacity_per_1000"] == 92.0
|
||
|
||
|
||
def test_saturation_hospital_cohort_is_factual():
|
||
"""Поликлиника нормируется на всё население → cohort НЕ оценка (факт)."""
|
||
out = compute_district_saturation(_MockDb(_chkalovsky_row()), "Чкаловский")
|
||
assert out is not None
|
||
hospital = out["categories"]["hospital"]
|
||
assert hospital["cohort_estimated"] is False
|
||
assert hospital["cohort_population"] == 286277
|
||
|
||
|
||
def test_saturation_deficit_district_school_gets_boost():
|
||
"""#42 acceptance: дефицитный по школам район → school POI score_multiplier=1.2.
|
||
|
||
Чкаловский, 12 школ, школьники ≈ 32922 чел. (286277×0.115).
|
||
Эталон-места = 32922/1000 × 92 ≈ 3029. Факт = 12 × 600 = 7200…
|
||
при таком capacity это профицит. Сужаем когорту: мало школ на много детей →
|
||
проверяем boundary через малое число объектов.
|
||
"""
|
||
# 1 школа на крупный район → острый дефицит → ×1.2.
|
||
out = compute_district_saturation(_MockDb(_chkalovsky_row(n_school=1)), "Чкаловский")
|
||
assert out is not None
|
||
school = out["categories"]["school"]
|
||
assert school["severity"] in ("дефицит", "острый дефицит")
|
||
assert school["score_multiplier"] == _MULT_DEFICIT
|
||
|
||
|
||
def test_saturation_ratio_matches_pure_helper():
|
||
"""DB-слой использует ту же чистую формулу, что и compute_provision_ratio."""
|
||
row = _chkalovsky_row(n_school=4)
|
||
out = compute_district_saturation(_MockDb(row), "Чкаловский")
|
||
assert out is not None
|
||
expected = compute_provision_ratio(
|
||
poi_count=4, population=286277, age_share=0.115, category="school"
|
||
)
|
||
assert expected is not None
|
||
assert out["categories"]["school"]["provision_ratio"] == round(expected, 3)
|