gendesign/backend/tests/test_saturation.py
bot-backend c52c2c4063
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m5s
CI / backend-tests (pull_request) Successful in 15m6s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
fix(site_finder): SAVEPOINT-isolate DB errors across cluster A (#2464)
developer_attribution, connection_capacity_lookup (gas/heat helpers),
competitors, saturation, supply_layers, and pat_lookup swallowed DB errors
without begin_nested()/rollback(), leaving the shared analyze-request
Session aborted for every subsequent db.execute (silently breaking
persist_analysis_run and the forecast enqueue). zone_regulation's
backfill_ekb_zone_regulations gets a plain rollback() since it only ever
runs on its own owned Celery-task session.

Refs #2464 (cluster A session-poisoning).
2026-07-07 17:32:32 +05:00

277 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)