All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m36s
Финальный PR issue #2045 (BE-3): GET /api/v1/trade-in/location-coef для LocationDrawer. FDW foreign table -> локальное зеркало osm_poi_ekb_local (TRUNCATE+INSERT, тот же паттерн что cad_buildings_local/cadastral_geo_match, избегает ~1.16s/row FDW round-trip) -> straight-line POI-скоринг, портированный из Site Finder poi_score.py::compute_poi_weighted_top7 (CATEGORY_WEIGHTS as-is, радиус 1200м для квартир вместо Ptica 2000м для участков). score->coef - новая MVP-эвристика (0.95..1.05, не откалибрована на реальных дельтах). Graceful fallback (не 500, не фабрикуем факторы): пустая/не отрефрешенная osm_poi_ekb_local или отсутствие lat/lon у оценки -> coef=1.0, factors=[], geo_source="unavailable". Scheduler: source=osm_poi_ekb_refresh, daily, зарегистрирован и в боевом dispatch (scheduler.py), и в kit-registry (product_handlers.py) - иначе test_kit_registry_completeness падает на ship-dark инварианте (#2192). Frontend wiring (mappers.ts/LocationDrawer.tsx) - вне scope, отдельная задача после проверки endpoint'а curl'ом на деплое.
178 lines
7.3 KiB
Python
178 lines
7.3 KiB
Python
"""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 normalization constant (31.5)."""
|
||
assert lc._TOP7_WEIGHT_SUM == 31.5
|
||
assert abs(lc._MAX_STRAIGHT_SCORE - 0.315) < 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_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))
|