"""Unit tests for app.services.location_coef (#2045 BE-3, LocationDrawer). No live Postgres needed — DB is a minimal fake returning queued results (mirrors the convention in tests/tasks/test_cadastral_geo_match.py). Covers: - pure functions: _category_weight, _score_to_coef, normalization constants - compute_location_coef: weighted top-N scoring, empty-mirror graceful fallback, no-POI-in-radius (legit zero-score, NOT "unavailable") """ 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_coef as lc # ── 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_top7_weight_sum_matches_ptica() -> None: """Same category set as Site Finder → identical top-7 weight-sum constant (31.5).""" assert lc._TOP7_WEIGHT_SUM == 31.5 def test_max_straight_score_normalized_at_reference_distance_not_zero() -> None: """Audit R2 #7a: denominator uses _REF_DISTANCE_M (100m), not an unreachable d=0 max. Old d=0 normalization was _TOP7_WEIGHT_SUM / 100 == 0.315 — the theoretical max with all top-7 categories AT the doorstep. That compressed realistic scores (~25-30/100 for even a strong central address) into the bottom third of the range. The new normalization halves the max (denominator distance+100 doubles from 100 to 200 at REF=100m), doubling realistic scores instead. """ assert lc._REF_DISTANCE_M == 100.0 assert abs(lc._MAX_STRAIGHT_SCORE - 0.1575) < 1e-9 def test_score_to_coef_bounds() -> None: assert lc._score_to_coef(0.0) == 0.95 assert lc._score_to_coef(100.0) == 1.05 def test_score_to_coef_midpoint() -> None: assert lc._score_to_coef(50.0) == 1.0 def test_score_to_coef_is_monotonic() -> None: scores = [0.0, 10.0, 25.0, 50.0, 75.0, 90.0, 100.0] coefs = [lc._score_to_coef(s) for s in scores] assert coefs == sorted(coefs) # ── compute_location_coef with a fake DB ───────────────────────────────────── class _FakeResult: def __init__(self, *, scalar_value: Any = None, mapping_rows: list[dict] | None = None): self._scalar_value = scalar_value self._mapping_rows = mapping_rows or [] def scalar(self) -> Any: return self._scalar_value def mappings(self) -> Any: class _Mappings: def __init__(self, rows: list[dict]) -> None: self._rows = rows def all(self) -> list[dict]: return self._rows return _Mappings(self._mapping_rows) 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_compute_location_coef_empty_mirror_returns_unavailable() -> None: """osm_poi_ekb_local not yet refreshed (count=0) → unavailable, no fabricated factors.""" db = _FakeDB([_FakeResult(scalar_value=0)]) result = lc.compute_location_coef(db, lat=56.84, lon=60.6) assert result.coef == 1.0 assert result.factors == [] assert result.geo_source == "unavailable" # Only the count probe ran — no nearest-POI query issued against an empty mirror. assert len(db.executed) == 1 def test_compute_location_coef_no_poi_in_radius_is_legit_zero_score() -> None: """Mirror populated (count>0) but nothing within radius → coef floor, NOT unavailable.""" db = _FakeDB( [ _FakeResult(scalar_value=500), # mirror has rows elsewhere _FakeResult(mapping_rows=[]), # nothing near this point ] ) result = lc.compute_location_coef(db, lat=56.84, lon=60.6) assert result.factors == [] assert result.geo_source == "osm_poi_ekb" assert result.coef == lc._score_to_coef(0.0) == 0.95 def test_compute_location_coef_weights_and_ranks_top_n() -> None: """Nearer + higher-weight-category POI ranks above farther/lower-weight ones.""" 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)]) result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7) assert result.geo_source == "osm_poi_ekb" assert len(result.factors) == 4 # metro_stop (weight 6.0) at 150m beats school (5.0) at 300m despite being closer only # marginally — sanity check the ranking is weight-driven, not distance-only. assert result.factors[0].poi_type == "metro_stop" # Weights strictly descending (sorted DESC by weight before slicing to top_n). weights = [f.weight for f in result.factors] assert weights == sorted(weights, reverse=True) # coef must land inside the documented [0.95, 1.05] MVP range. assert 0.95 <= result.coef <= 1.05 def test_compute_location_coef_strong_central_fixture_lands_near_one() -> None: """Audit R2 #7a: a realistic strong central-EKB POI profile now reads as ~1.0, not ~0.98. Fixture mirrors a real strong central address audit: near transit/kindergarten/pharmacy, but school/metro/mall noticeably farther (a typical quarter profile, NOT everything at the doorstep). Under the OLD d=0 normalization this fixture scores ~28/100 → coef ~0.978 (verified separately against the pre-fix formula). After re-centering on _REF_DISTANCE_M it should land close to 1.0 (within the documented ±0.01 calibration target). """ rows = [ {"name": "Остановка", "category": "bus_stop", "distance_m": 80.0}, {"name": "Аптека", "category": "pharmacy", "distance_m": 120.0}, {"name": "Супермаркет", "category": "shop_supermarket", "distance_m": 180.0}, {"name": "Детсад №5", "category": "kindergarten", "distance_m": 220.0}, {"name": "Школа №10", "category": "school", "distance_m": 350.0}, {"name": "Метро Геологическая", "category": "metro_stop", "distance_m": 500.0}, {"name": "ТЦ Гринвич", "category": "shop_mall", "distance_m": 700.0}, ] db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)]) result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7) assert result.geo_source == "osm_poi_ekb" assert len(result.factors) == 7 assert abs(result.coef - 1.0) <= 0.01 def test_compute_location_coef_weak_fixture_stays_well_below_one() -> None: """A sparse/poor location (only far, low-weight POI) must still stay well below 1.0.""" rows = [ {"name": "Магазинчик", "category": "shop_small", "distance_m": 950.0}, {"name": "Прочее", "category": "some_unknown_tag", "distance_m": 1100.0}, ] db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)]) result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7) assert result.geo_source == "osm_poi_ekb" assert result.coef < 0.98 def test_compute_location_coef_limits_to_top_n() -> None: """More than top_n candidates → only top_n factors surface in the response.""" 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)]) result = lc.compute_location_coef(db, lat=56.84, lon=60.6, top_n=7) assert len(result.factors) == 7 def test_compute_location_coef_unknown_category_uses_default_weight() -> None: rows = [{"name": "Неизвестный POI", "category": "some_new_osm_tag", "distance_m": 200.0}] db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=rows)]) result = lc.compute_location_coef(db, lat=56.84, lon=60.6) assert len(result.factors) == 1 expected_weight = (1.0 / (200.0 + 100.0)) * lc.CATEGORY_WEIGHTS["default"] assert abs(result.factors[0].weight - round(expected_weight, 6)) < 1e-9 def test_compute_location_coef_passes_radius_param() -> None: """radius_m is forwarded as a bound param (psycopg v3 CAST discipline, no :p::type).""" db = _FakeDB([_FakeResult(scalar_value=1), _FakeResult(mapping_rows=[])]) lc.compute_location_coef(db, lat=56.84, lon=60.6, radius_m=1500) _clause, params = db.executed[1] assert params is not None assert params["radius_m"] == 1500 def test_no_psycopg_v3_colon_colon_cast() -> None: """psycopg v3: never :param::type — must use CAST(:param AS type).""" import re assert not re.search(r":\w+::", str(lc._NEAREST_POI_SQL.text))