gendesign/backend/tests/test_poi_score.py
bot-backend b534579e1b
Some checks failed
CI / changes (pull_request) Successful in 8s
CI / changes (push) Successful in 9s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / frontend-tests (pull_request) Successful in 49s
CI / openapi-codegen-check (pull_request) Failing after 1m35s
CI / backend-tests (pull_request) Failing after 8m47s
fix(site-finder): correct POI-score mock shape+values, routing empty assert (#1486 review)
- Rename top_poi → items in poi-score.json to match PoiScoreResponse TS type
  (mock was cast as PoiScoreResponse but had wrong field name → items undefined
  at runtime in MOCK_POI_SCORE mode → PoiList2Gis crashed at [...items].sort)
- Recompute all score_contribution values using backend formula
  (weight / _MAX_STRAIGHT_SCORE * 100, _MAX_STRAIGHT_SCORE=0.315) and
  poi_weighted_score=19.9 (was 72, which was inconsistent with the new normalization)
- Add assert result.poi_weighted_score == 0.0 to test_routing_decay_empty_db
  to match the straight-line empty-db assertion
- Remove stale comment in PoiList2Gis.tsx saying normalization needs fixing in
  site-finder-api.ts (already done backend-side in this PR)
2026-06-17 20:47:01 +03:00

406 lines
16 KiB
Python
Raw Permalink 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 weighted score service (B6).
Юнит-тесты для чистой функции — без DB.
"""
import pytest
from app.services.site_finder import ors_client, poi_score
from app.services.site_finder.poi_score import (
_MAX_ROUTING_SCORE,
_MAX_STRAIGHT_SCORE,
CATEGORY_WEIGHTS,
PoiScoreResponse,
_category_radius_min,
_category_weight,
_decay_linear,
_decay_piecewise,
_decay_step,
compute_poi_routing_decay,
compute_poi_weighted_top7,
)
# ── unit: _category_weight ─────────────────────────────────────────────────────
def test_category_weight_metro():
"""Метро имеет наибольший вес из всех категорий."""
metro_w = _category_weight("metro_stop")
for cat in CATEGORY_WEIGHTS:
if cat != "metro_stop" and cat != "default":
assert metro_w >= _category_weight(
cat
), f"metro_stop weight {metro_w} должен быть >= {cat} weight {_category_weight(cat)}"
def test_category_weight_unknown_returns_default():
w = _category_weight("unknown_category_xyz")
assert w == CATEGORY_WEIGHTS["default"]
def test_category_weight_all_positive():
"""Все веса в CATEGORY_WEIGHTS должны быть положительными (B6 — ranking, не штраф)."""
for cat, w in CATEGORY_WEIGHTS.items():
assert w > 0, f"Вес {cat}={w} должен быть > 0"
# ── unit: weight formula ratio ─────────────────────────────────────────────────
def test_weight_formula_ratio():
"""Ближний объект той же категории должен иметь больший вес."""
cat = "school"
cw = _category_weight(cat)
w_near = (1.0 / (100.0 + 100.0)) * cw # 100м
w_far = (1.0 / (1000.0 + 100.0)) * cw # 1000м
assert w_near > w_far
def test_weight_formula_category_dominates_at_equal_distance():
"""При одинаковом расстоянии метро должно быть впереди автобусной остановки."""
dist = 500.0
w_metro = (1.0 / (dist + 100.0)) * _category_weight("metro_stop")
w_bus = (1.0 / (dist + 100.0)) * _category_weight("bus_stop")
assert w_metro > w_bus
# ── unit: compute_poi_weighted_top7 with mock DB ───────────────────────────────
class _MockMappings:
def __init__(self, data: list[dict]) -> None:
self._data = data
def all(self) -> list[dict]:
return self._data # type: ignore[return-value]
class _MockResult:
def __init__(self, data: list[dict]) -> None:
self._data = data
def mappings(self) -> "_MockMappings":
return _MockMappings(self._data)
class _MockDb:
"""Минимальный мок SQLAlchemy Session для тестирования без БД."""
def __init__(self, rows: list[dict]) -> None:
self._rows = rows
def execute(self, *_args: object, **_kwargs: object) -> _MockResult:
return _MockResult(self._rows)
def _make_row(name: str, category: str, distance_m: float) -> dict:
return {
"name": name,
"category": category,
"tags": {},
"distance_m": distance_m,
}
def test_top7_returns_at_most_7():
rows = [_make_row(f"POI {i}", "school", float(i * 50)) for i in range(1, 20)]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
assert isinstance(result, PoiScoreResponse)
assert len(result.top_poi) <= 7
def test_top7_sorted_by_weight_desc():
rows = [
_make_row("Дальняя школа", "school", 1500.0),
_make_row("Метро", "metro_stop", 300.0),
_make_row("Близкая школа", "school", 100.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
weights = [item.weight for item in result.top_poi]
assert weights == sorted(weights, reverse=True), "top_poi должны быть по weight DESC"
def test_metro_beats_school_at_equal_distance():
"""Метро в 300м должно быть на первом месте перед школой в 300м (равное расстояние)."""
rows = [
_make_row("Школа №1", "school", 300.0),
_make_row("Метро Чкаловская", "metro_stop", 300.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
assert (
result.top_poi[0].category == "metro_stop"
), "При равном расстоянии метро (category_weight=6.0) должно быть выше школы (5.0)"
def test_metro_first_when_close():
"""Метро в 50м должно быть на первом месте перед школой в 300м."""
rows = [
_make_row("Школа №1", "school", 300.0),
_make_row("Метро Чкаловская", "metro_stop", 50.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
assert result.top_poi[0].category == "metro_stop", (
"Метро (weight=6.0) в 50м должно быть впереди школы (weight=5.0) в 300м — "
f"metro_weight={(1/(50+100))*6:.5f} vs school_weight={(1/(300+100))*5:.5f}"
)
def test_empty_db_returns_empty_top_poi():
db = _MockDb([])
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
assert result.top_poi == []
assert result.cad_num == "66:41:0204016:10"
assert result.radius_m == 2000
assert result.poi_weighted_score == 0.0
# ── #1486: normalization 0..100 ────────────────────────────────────────────────
def test_max_straight_score_constant():
"""_MAX_STRAIGHT_SCORE = Σ(top-7 category_weights) / 100 ≈ 0.315."""
top7_sum = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
assert _MAX_STRAIGHT_SCORE == pytest.approx(top7_sum / 100.0)
def test_max_routing_score_constant():
"""_MAX_ROUTING_SCORE = Σ(top-7 category_weights) = 31.5."""
top7_sum = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
assert _MAX_ROUTING_SCORE == pytest.approx(top7_sum)
def test_poi_weighted_score_in_range():
"""poi_weighted_score должен быть в диапазоне 0..100 для реалистичных данных."""
rows = [
_make_row("Метро", "metro_stop", 400.0),
_make_row("Школа", "school", 500.0),
_make_row("Детсад", "kindergarten", 300.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
assert 0.0 <= result.poi_weighted_score <= 100.0
def test_score_contribution_in_range():
"""Каждый score_contribution должен быть в 0..100."""
rows = [
_make_row("Метро", "metro_stop", 200.0),
_make_row("Школа", "school", 800.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
for item in result.top_poi:
assert (
0.0 <= item.score_contribution <= 100.0
), f"{item.category} score_contribution={item.score_contribution} вне 0..100"
def test_metro_at_zero_distance_scores_high():
"""Метро в 0м должно дать poi_weighted_score близко к max (≥19.0/100)."""
# metro weight = 6.0 / (0 + 100) = 0.06; normalized = 0.06 / _MAX_STRAIGHT_SCORE * 100
rows = [_make_row("Метро у дома", "metro_stop", 0.0)]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
assert (
result.poi_weighted_score >= 19.0
), f"Метро у дома (d=0) должно давать ≥19/100, получили {result.poi_weighted_score}"
def test_score_contribution_sum_equals_total():
"""Сумма score_contribution по top_poi должна совпадать с poi_weighted_score.
Допуск 0.5 — оба значения округлены независимо до 1 знака, накопленная
ошибка при N POI ≤ N * 0.05. Для top-7 это ≤ 0.35 → допуск 0.5 достаточен.
"""
rows = [
_make_row("Метро", "metro_stop", 300.0),
_make_row("Школа", "school", 600.0),
_make_row("Парк", "park", 200.0),
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
total_from_contributions = sum(i.score_contribution for i in result.top_poi)
assert total_from_contributions == pytest.approx(result.poi_weighted_score, abs=0.5)
def test_address_built_from_tags():
rows = [
{
"name": "Магазин",
"category": "shop_small",
"tags": {"addr:street": "ул. Ленина", "addr:housenumber": "10"},
"distance_m": 200.0,
}
]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "test", 56.838, 60.605)
assert result.top_poi[0].address == "ул. Ленина, 10"
def test_address_none_when_no_tags():
rows = [_make_row("Парк", "park", 400.0)]
db = _MockDb(rows)
result = compute_poi_weighted_top7(db, "test", 56.838, 60.605)
assert result.top_poi[0].address is None
# ── #41: per-category decay curves (pure) ────────────────────────────────────
def test_decay_linear_bounds():
assert _decay_linear(0.0, 20.0) == pytest.approx(1.0)
assert _decay_linear(10.0, 20.0) == pytest.approx(0.5)
assert _decay_linear(20.0, 20.0) == 0.0
assert _decay_linear(25.0, 20.0) == 0.0 # за порогом → 0
def test_decay_piecewise_full_then_drop():
"""Школа: полный вес близко, спад к краю, 0 за порогом."""
r = 15.0
assert _decay_piecewise(2.0, r) == pytest.approx(1.0) # 0..5 мин полный
assert _decay_piecewise(5.0, r) == pytest.approx(1.0)
mid = _decay_piecewise(10.0, r)
assert 0.0 < mid < 1.0
assert _decay_piecewise(15.0, r) == 0.0
assert _decay_piecewise(99.0, r) == 0.0
def test_decay_step_binary():
"""Парк: есть в радиусе (1.0) либо нет (0.0)."""
assert _decay_step(9.0, 10.0) == 1.0
assert _decay_step(10.0, 10.0) == 1.0
assert _decay_step(10.1, 10.0) == 0.0
def test_category_radius_min_defaults():
assert _category_radius_min("metro_stop") == 20.0
assert _category_radius_min("park") == 10.0
# неизвестная категория → дефолт
assert _category_radius_min("unknown_xyz") == poi_score._DEFAULT_RADIUS_MIN
# ── #41: compute_poi_routing_decay (mock DB + monkeypatched ORS) ─────────────
def _make_routing_row(name: str, category: str, distance_m: float, lon=60.6, lat=56.8) -> dict:
return {
"name": name,
"category": category,
"tags": {},
"lon": lon,
"lat": lat,
"distance_m": distance_m,
}
def test_routing_decay_drops_poi_beyond_radius(monkeypatch):
"""POI за порогом доступности (минут) выбывает из результата."""
rows = [
_make_routing_row("Близкое метро", "metro_stop", 400.0),
_make_routing_row("Далёкая школа", "school", 1800.0),
]
db = _MockDb(rows)
# ORS: метро 5 мин (в пределах 20), школа 30 мин (за порогом 15) → школа выбывает.
def fake_matrix(_lon, _lat, dests, **_kw):
return [5.0, 30.0]
monkeypatch.setattr(ors_client, "matrix_durations_min", fake_matrix)
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
result = compute_poi_routing_decay(db, "66:41:0204016:10", 56.838, 60.605)
cats = [i.category for i in result.top_poi]
assert "metro_stop" in cats
assert "school" not in cats, "школа в 30 мин (> 15 мин порог) должна выбыть"
def test_routing_decay_closer_time_higher_weight(monkeypatch):
"""При одинаковой категории меньшее время в пути → больший вес."""
rows = [
_make_routing_row("Школа дальняя", "school", 900.0),
_make_routing_row("Школа ближняя", "school", 200.0),
]
db = _MockDb(rows)
def fake_matrix(_lon, _lat, dests, **_kw):
return [12.0, 3.0] # дальняя 12 мин, ближняя 3 мин
monkeypatch.setattr(ors_client, "matrix_durations_min", fake_matrix)
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
assert result.top_poi[0].name == "Школа ближняя"
def test_routing_decay_falls_back_to_straight_line(monkeypatch):
"""ORS недоступен → straight-line fallback, без исключения наружу."""
rows = [_make_routing_row("Метро", "metro_stop", 300.0)]
db = _MockDb(rows)
def raising_matrix(*_a, **_kw):
raise ors_client.OrsUnavailableError("no key")
monkeypatch.setattr(ors_client, "matrix_durations_min", raising_matrix)
result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
# 300м / 83 м/мин ≈ 3.6 мин — в пределах 20 мин радиуса метро → остаётся.
assert len(result.top_poi) == 1
assert result.top_poi[0].category == "metro_stop"
def test_routing_decay_none_route_fallback(monkeypatch):
"""ORS вернул None (нет маршрута) для точки → straight-line оценка времени."""
rows = [_make_routing_row("Парк", "park", 400.0)]
db = _MockDb(rows)
def fake_matrix(_lon, _lat, dests, **_kw):
return [None] # ORS не построил маршрут
monkeypatch.setattr(ors_client, "matrix_durations_min", fake_matrix)
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
# 400м / 83 ≈ 4.8 мин < 10 мин (park step) → парк остаётся.
assert len(result.top_poi) == 1
def test_routing_decay_empty_db(monkeypatch):
db = _MockDb([])
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
assert result.top_poi == []
assert result.poi_weighted_score == 0.0
def test_routing_decay_score_spread_wider_than_straight_line(monkeypatch):
"""#41 acceptance: routing-decay даёт более широкий разброс между хорошим и
плохим участком, чем straight-line (1/(d+100)).
Хороший участок: метро 4 мин, школа 6 мин. Плохой: метро 18 мин, школа 14 мин.
"""
def total(rows, times):
db = _MockDb(rows)
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
monkeypatch.setattr(ors_client, "matrix_durations_min", lambda *_a, **_k: list(times))
res = compute_poi_routing_decay(db, "cad", 56.8, 60.6)
return sum(i.weight for i in res.top_poi)
good_rows = [
_make_routing_row("Метро", "metro_stop", 300.0),
_make_routing_row("Школа", "school", 450.0),
]
bad_rows = [
_make_routing_row("Метро", "metro_stop", 1400.0),
_make_routing_row("Школа", "school", 1100.0),
]
good = total(good_rows, [4.0, 6.0])
bad = total(bad_rows, [18.0, 14.0])
assert good > 0 and bad > 0
# routing-decay усиливает контраст: хороший минимум вдвое выше плохого.
assert good / bad > 2.0, f"ожидали разброс >2×, получили {good / bad:.2f}×"