"""Tests for POI saturation per capita (#42). Pure-функции (severity / multiplier / provision_ratio) — без БД. DB-слой compute_district_saturation — через минимальный mock Session (тот же приём, что в test_poi_score.py). """ 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).""" def __init__(self, row: dict | None, *, raise_on_execute: bool = False) -> None: self._row = row self._raise = raise_on_execute 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_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)