"""Unit tests for app.services.location_index (replaces test_location_coef.py). No live Postgres needed — DB is a minimal fake returning queued results (mirrors the convention in tests/tasks/test_cadastral_geo_match.py / the deleted test_location_coef.py). Covers: - pure functions: _category_weight, _in_ekb_bbox, _pct_deviation (incl. monotonicity) - _fetch_nearby_poi: qualitative POI ranking (unchanged behaviour from the old module) - compute_location_index: out-of-coverage degradation, insufficient-sample degradation (citywide AND local), radius-ladder expansion, explicit radius_m override, happy path - SQL discipline: psycopg v3 CAST, percentile_cont (not naive AVG/MIN/MAX) for outlier robustness """ from __future__ import annotations import os from typing import Any # psycopg v3 driver required; stub DATABASE_URL before any app import (settings needs a DSN). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services import location_index as lc # A point well inside the EKB coverage bbox (city centre, Ploshchad 1905 goda area). _LAT_IN_EKB = 56.838 _LON_IN_EKB = 60.605 # ── Pure functions ────────────────────────────────────────────────────────── def test_category_weight_known_categories() -> None: assert lc._category_weight("metro_stop") == 6.0 assert lc._category_weight("school") == 5.0 assert lc._category_weight("kindergarten") == 4.5 assert lc._category_weight("hospital") == 4.0 assert lc._category_weight("shop_mall") == 4.0 assert lc._category_weight("shop_supermarket") == 3.5 assert lc._category_weight("bus_stop") == 4.5 assert lc._category_weight("park") == 3.5 assert lc._category_weight("pharmacy") == 2.5 assert lc._category_weight("tram_stop") == 2.0 assert lc._category_weight("shop_small") == 2.0 def test_category_weight_unknown_and_none_fall_back_to_default() -> None: assert lc._category_weight("unknown_category") == 1.0 assert lc._category_weight(None) == 1.0 def test_in_ekb_bbox_center_is_inside() -> None: assert lc._in_ekb_bbox(_LAT_IN_EKB, _LON_IN_EKB) is True def test_in_ekb_bbox_bounds_are_inclusive() -> None: assert lc._in_ekb_bbox(56.70, 60.50) is True assert lc._in_ekb_bbox(56.95, 60.75) is True def test_in_ekb_bbox_outside_is_rejected() -> None: # Nizhny Tagil — same oblast (region_code=66), well outside the EKB product bbox. assert lc._in_ekb_bbox(57.910, 59.970) is False # Just past each edge of the bbox. assert lc._in_ekb_bbox(56.69, 60.60) is False assert lc._in_ekb_bbox(56.96, 60.60) is False assert lc._in_ekb_bbox(56.80, 60.49) is False assert lc._in_ekb_bbox(56.80, 60.76) is False def test_pct_deviation_above_and_below_city_median() -> None: assert lc._pct_deviation(165_000.0, 150_000.0) == 10.0 assert lc._pct_deviation(135_000.0, 150_000.0) == -10.0 assert lc._pct_deviation(150_000.0, 150_000.0) == 0.0 def test_pct_deviation_not_artificially_clamped() -> None: """Owner requirement: a genuinely +40% district must read as +40%, not clamped.""" assert lc._pct_deviation(210_000.0, 150_000.0) == 40.0 def test_pct_deviation_guards_zero_division() -> None: assert lc._pct_deviation(100_000.0, 0.0) == 0.0 def test_pct_deviation_is_monotonic_in_local_median() -> None: """Индекс строго монотонен по локальной медиане при фиксированной городской — в отличие от старого coef (немонотонные бакеты на реальных данных, см. модуль docstring). Значения ниже — медианы ₽/м² по дистанционным бакетам от центра ЕКБ, измеренные на 31 тыс. лотов (аудит владельца продукта), отсортированные по возрастанию. Индекс, построенный на этих же локальных медианах, обязан сохранить порядок. """ city_median = 155_000.0 local_medians_ascending = [ 93_677.0, 136_729.0, 150_063.0, 159_382.0, 159_486.0, 191_682.0, 249_686.0, ] pct_values = [lc._pct_deviation(m, city_median) for m in local_medians_ascending] assert pct_values == sorted(pct_values) # ── SQL discipline ──────────────────────────────────────────────────────────── def test_no_psycopg_v3_colon_colon_cast() -> None: """psycopg v3: never :param::type — must use CAST(:param AS type).""" import re for sql in ( lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL, lc._NEAREST_POI_SQL, ): assert not re.search(r":\w+::", str(sql.text)) def test_median_queries_use_percentile_not_naive_minmax() -> None: """Outlier robustness requirement: percentile_cont(0.5) (median), not AVG/MIN/MAX.""" for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL): sql_text = str(sql.text).lower() assert "percentile_cont(0.5)" in sql_text assert "avg(" not in sql_text assert "min(" not in sql_text assert "max(" not in sql_text def test_median_queries_exclude_city_centroid_and_bound_bbox() -> None: """Comparable-selection quality control (owner requirement #1): city-centroid geocodes excluded (mirrors estimator.py #769 Part E), sample bounded to the EKB bbox.""" for sql in (lc._MEDIAN_PPM2_LOCAL_SQL, lc._MEDIAN_PPM2_CITYWIDE_SQL): sql_text = str(sql.text) assert "geo_precision IS DISTINCT FROM 'city'" in sql_text assert "bbox_south" in sql_text and "bbox_north" in sql_text assert "bbox_west" in sql_text and "bbox_east" in sql_text # ── _fetch_nearby_poi (qualitative "что рядом" list) ───────────────────────── class _FakeResult: def __init__( self, *, scalar_value: Any = None, mapping_rows: list[dict] | None = None, mapping_one: dict | None = None, ): self._scalar_value = scalar_value self._mapping_rows = mapping_rows or [] self._mapping_one = mapping_one def scalar(self) -> Any: return self._scalar_value def mappings(self) -> Any: outer = self class _Mappings: def all(self) -> list[dict]: return outer._mapping_rows def first(self) -> dict | None: return outer._mapping_one return _Mappings() class _FakeDB: """Minimal Session stand-in: execute() returns queued results in order.""" def __init__(self, results: list[_FakeResult]) -> None: self._results = list(results) self.executed: list[Any] = [] def execute(self, clause: Any, params: dict | None = None) -> _FakeResult: self.executed.append((clause, params)) return self._results.pop(0) def test_fetch_nearby_poi_empty_mirror_returns_unavailable() -> None: db = _FakeDB([_FakeResult(scalar_value=0)]) poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7) assert poi == [] assert status == "unavailable" assert len(db.executed) == 1 # only the count probe ran def test_fetch_nearby_poi_no_poi_in_radius_is_legit_ok() -> None: db = _FakeDB([_FakeResult(scalar_value=500), _FakeResult(mapping_rows=[])]) poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, lc.DEFAULT_POI_RADIUS_M, 7) assert poi == [] assert status == "ok" def test_fetch_nearby_poi_ranks_by_weight_not_distance_only() -> None: rows = [ {"name": "Школа №1", "category": "school", "distance_m": 300.0}, {"name": "ТЦ Мега", "category": "shop_mall", "distance_m": 900.0}, {"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0}, {"name": "Аптека", "category": "pharmacy", "distance_m": 50.0}, ] db = _FakeDB([_FakeResult(scalar_value=1000), _FakeResult(mapping_rows=rows)]) poi, status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7) assert status == "ok" assert len(poi) == 4 # metro_stop (weight 6.0) at 150m outranks school (5.0) at 300m — weight-driven, not # distance-only ranking. assert poi[0].poi_type == "metro_stop" def test_fetch_nearby_poi_limits_to_top_n() -> None: rows = [ {"name": f"POI {i}", "category": "shop_small", "distance_m": float(100 + i * 10)} for i in range(20) ] db = _FakeDB([_FakeResult(scalar_value=20), _FakeResult(mapping_rows=rows)]) poi, _status = lc._fetch_nearby_poi(db, _LAT_IN_EKB, _LON_IN_EKB, 1200, 7) assert len(poi) == 7 # ── compute_location_index: degradation + ladder logic ─────────────────────── def test_compute_location_index_out_of_coverage_skips_all_db_calls() -> None: """Owner requirement #2: point outside EKB → honest 'no data', never a fallback number. Also a perf/honesty check: no DB round-trip at all for an out-of-scope point. """ db = _FakeDB([]) result = lc.compute_location_index(db, lat=57.910, lon=59.970) # Nizhny Tagil assert result.status == "out_of_coverage" assert result.location_index_pct is None assert result.local_median_price_per_m2 is None assert result.city_median_price_per_m2 is None assert result.sample_size == 0 assert result.nearby_poi == [] assert result.poi_status == "unavailable" assert db.executed == [] def test_compute_location_index_citywide_sample_too_small_short_circuits() -> None: """Degenerate citywide reference (e.g. empty dev DB) → insufficient_data without ever issuing a local-radius query (nothing to compare against anyway).""" db = _FakeDB( [ _FakeResult(scalar_value=0), # POI mirror empty _FakeResult(mapping_one={"median_ppm2": None, "n": 3}), # citywide: n < MIN ] ) result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) assert result.status == "insufficient_data" assert result.location_index_pct is None assert result.sample_size == 3 assert len(db.executed) == 2 # poi-count + citywide only — no radius-ladder queries def test_compute_location_index_first_radius_rung_sufficient() -> None: db = _FakeDB( [ _FakeResult(scalar_value=0), # poi mirror empty _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), # citywide _FakeResult(mapping_one={"median_ppm2": 165_000.0, "n": 25}), # radius[0]=800 ] ) result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) assert result.status == "ok" assert result.radius_m == lc.RADIUS_LADDER_M[0] assert result.sample_size == 25 assert result.local_median_price_per_m2 == 165_000 assert result.city_median_price_per_m2 == 150_000 assert result.location_index_pct == 10.0 assert len(db.executed) == 3 # ladder stopped at rung 1 — no further radius queries def test_compute_location_index_expands_ladder_when_first_rung_insufficient() -> None: db = _FakeDB( [ _FakeResult(scalar_value=0), _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), _FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 10}), # 800m: too few _FakeResult(mapping_one={"median_ppm2": 180_000.0, "n": 30}), # 1500m: enough ] ) result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) assert result.status == "ok" assert result.radius_m == lc.RADIUS_LADDER_M[1] assert result.sample_size == 30 assert len(db.executed) == 4 def test_compute_location_index_insufficient_even_at_max_radius() -> None: db = _FakeDB( [ _FakeResult(scalar_value=0), _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), _FakeResult(mapping_one={"median_ppm2": 200_000.0, "n": 5}), # 800m _FakeResult(mapping_one={"median_ppm2": 195_000.0, "n": 12}), # 1500m _FakeResult(mapping_one={"median_ppm2": 190_000.0, "n": 15}), # 2500m — still < 20 ] ) result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) assert result.status == "insufficient_data" assert result.location_index_pct is None assert result.local_median_price_per_m2 is None assert result.city_median_price_per_m2 == 150_000 assert result.radius_m == lc.RADIUS_LADDER_M[-1] assert result.sample_size == 15 # honest: shows how close it got, not just "no data" assert len(db.executed) == 5 # exhausted the full ladder def test_compute_location_index_explicit_radius_skips_ladder() -> None: """An explicit radius_m must be used AS-IS — no adaptive expansion (caller-controlled).""" db = _FakeDB( [ _FakeResult(scalar_value=0), _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), _FakeResult(mapping_one={"median_ppm2": 160_000.0, "n": 50}), # single query only ] ) result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB, radius_m=1000) assert result.status == "ok" assert result.radius_m == 1000 assert len(db.executed) == 3 # exactly one radius query, no ladder rungs tried nearest_call_params = db.executed[2][1] assert nearest_call_params["radius_m"] == 1000 def test_compute_location_index_poi_unavailable_does_not_block_index() -> None: """poi_status and status degrade INDEPENDENTLY — an empty POI mirror must not prevent a perfectly computable price-based index.""" db = _FakeDB( [ _FakeResult(scalar_value=0), # poi mirror empty _FakeResult(mapping_one={"median_ppm2": 150_000.0, "n": 4000}), _FakeResult(mapping_one={"median_ppm2": 172_500.0, "n": 40}), ] ) result = lc.compute_location_index(db, lat=_LAT_IN_EKB, lon=_LON_IN_EKB) assert result.status == "ok" assert result.poi_status == "unavailable" assert result.nearby_poi == [] assert result.location_index_pct == 15.0