306 lines
14 KiB
Python
306 lines
14 KiB
Python
"""Unit-тесты relevance_weight конкурентов (#949 PR B, ТЗ §9.1, §16).
|
||
|
||
Чистые тесты pure-хелперов (без живой БД, без app.main):
|
||
• _normalize_class — нормализация obj_class (RU/EN/суффиксы/plus-формы).
|
||
• _geo_proximity — линейный decay расстояния (parcel→1, край радиуса→0).
|
||
• _class_similarity — карта по шагам в _CLASS_ORDER (same/adjacent/2/far),
|
||
нейтраль 0.5 при неизвестном классе.
|
||
• _price_similarity — близость к локальной медиане; нейтраль при None.
|
||
• _stage_at_horizon — horizon-aware проекция распродажи (sold-out→low,
|
||
активный→high, тонкие данные→0.5).
|
||
• _relevance_weight — взвешенное среднее (named weights, сумма 1.0).
|
||
• _dominant_class / _median — детерминированные эталоны.
|
||
|
||
Детерминированно, без LLM. Каждый граничный/тонкий кейс проверяет graceful
|
||
поведение (нейтраль 0.5 / clamp, никогда не crash).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import pytest
|
||
|
||
from app.services.site_finder.competitors import (
|
||
_CLASS_ORDER,
|
||
_NEUTRAL,
|
||
_W_CLASS,
|
||
_W_GEO,
|
||
_W_PRICE,
|
||
_W_STAGE,
|
||
_class_similarity,
|
||
_dominant_class,
|
||
_geo_proximity,
|
||
_median,
|
||
_normalize_class,
|
||
_price_similarity,
|
||
_relevance_weight,
|
||
_stage_at_horizon,
|
||
)
|
||
|
||
# ── weights sanity ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_weights_sum_to_one() -> None:
|
||
"""Named-веса саб-скоров образуют выпуклую комбинацию (сумма = 1.0)."""
|
||
assert _W_GEO + _W_CLASS + _W_PRICE + _W_STAGE == pytest.approx(1.0)
|
||
|
||
|
||
# ── _normalize_class ──────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestNormalizeClass:
|
||
def test_canonical_ru(self) -> None:
|
||
assert _normalize_class("комфорт") == "комфорт"
|
||
assert _normalize_class("Бизнес") == "бизнес"
|
||
|
||
def test_case_insensitive(self) -> None:
|
||
assert _normalize_class("КОМФОРТ") == "комфорт"
|
||
|
||
def test_english_synonyms(self) -> None:
|
||
# API-фильтр шлёт economy/comfort/business.
|
||
assert _normalize_class("economy") == "эконом"
|
||
assert _normalize_class("comfort") == "комфорт"
|
||
assert _normalize_class("business") == "бизнес"
|
||
assert _normalize_class("premium") == "премиум"
|
||
|
||
def test_plus_forms(self) -> None:
|
||
assert _normalize_class("комфорт+") == "комфорт+"
|
||
assert _normalize_class("комфорт плюс") == "комфорт+"
|
||
assert _normalize_class("Бизнес +") == "бизнес+"
|
||
|
||
def test_substring_suffix(self) -> None:
|
||
# «комфорт-класс», «класс «Бизнес»» из домрф aiDescription.
|
||
assert _normalize_class("комфорт-класс") == "комфорт"
|
||
assert _normalize_class("класс «Бизнес»") == "бизнес"
|
||
|
||
def test_plus_substring_not_swallowed_to_base(self) -> None:
|
||
# 'комфорт+' должен распознаться раньше 'комфорт' (длинный ключ первым).
|
||
assert _normalize_class("жильё комфорт+ класса") == "комфорт+"
|
||
|
||
def test_domrf_econom_synonyms(self) -> None:
|
||
# домрф канон эконома: стандарт / типовой (см. _OBJ_CLASS_PATTERNS).
|
||
assert _normalize_class("стандарт") == "стандарт"
|
||
assert _normalize_class("типовой") == "типовой"
|
||
|
||
def test_none_and_empty(self) -> None:
|
||
assert _normalize_class(None) is None
|
||
assert _normalize_class("") is None
|
||
assert _normalize_class(" ") is None
|
||
|
||
def test_unknown_returns_none(self) -> None:
|
||
assert _normalize_class("люксовый-апарт") is None
|
||
|
||
|
||
# ── _geo_proximity ────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestGeoProximity:
|
||
def test_at_parcel_is_one(self) -> None:
|
||
assert _geo_proximity(0.0, 1000.0) == 1.0
|
||
|
||
def test_at_radius_edge_is_zero(self) -> None:
|
||
assert _geo_proximity(1000.0, 1000.0) == 0.0
|
||
|
||
def test_midpoint(self) -> None:
|
||
assert _geo_proximity(500.0, 1000.0) == pytest.approx(0.5)
|
||
|
||
def test_beyond_radius_clamps_to_zero(self) -> None:
|
||
# За радиусом (не должно случаться — SQL фильтрует) → clamp 0, не negative.
|
||
assert _geo_proximity(1500.0, 1000.0) == 0.0
|
||
|
||
def test_none_distance_neutral(self) -> None:
|
||
assert _geo_proximity(None, 1000.0) == _NEUTRAL
|
||
|
||
def test_zero_radius_neutral(self) -> None:
|
||
assert _geo_proximity(100.0, 0.0) == _NEUTRAL
|
||
|
||
|
||
# ── _class_similarity ─────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestClassSimilarity:
|
||
def test_same_class(self) -> None:
|
||
assert _class_similarity("комфорт", "комфорт") == 1.0
|
||
|
||
def test_adjacent_class(self) -> None:
|
||
# комфорт(1) ↔ комфорт+(2) = 1 шаг → 0.6.
|
||
assert _class_similarity("комфорт", "комфорт+") == pytest.approx(0.6)
|
||
assert _class_similarity("комфорт+", "комфорт") == pytest.approx(0.6)
|
||
|
||
def test_two_steps(self) -> None:
|
||
# эконом(0) ↔ комфорт+(2) = 2 шага → 0.3.
|
||
assert _class_similarity("эконом", "комфорт+") == pytest.approx(0.3)
|
||
|
||
def test_far_class(self) -> None:
|
||
# эконом(0) ↔ премиум(5) = 5 шагов → 0.1.
|
||
assert _class_similarity("эконом", "премиум") == pytest.approx(0.1)
|
||
# бизнес(3) ↔ эконом(0) = 3 шага → far 0.1.
|
||
assert _class_similarity("бизнес", "эконом") == pytest.approx(0.1)
|
||
|
||
def test_cross_language_match(self) -> None:
|
||
# comp латиницей, эталон кириллицей → один класс → 1.0.
|
||
assert _class_similarity("comfort", "Комфорт") == 1.0
|
||
|
||
def test_unknown_competitor_neutral(self) -> None:
|
||
assert _class_similarity(None, "комфорт") == _NEUTRAL
|
||
assert _class_similarity("неведомый", "комфорт") == _NEUTRAL
|
||
|
||
def test_unknown_reference_neutral(self) -> None:
|
||
assert _class_similarity("комфорт", None) == _NEUTRAL
|
||
|
||
|
||
# ── _price_similarity ─────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestPriceSimilarity:
|
||
def test_on_median_is_one(self) -> None:
|
||
assert _price_similarity(100_000.0, 100_000.0) == 1.0
|
||
|
||
def test_fifty_pct_above(self) -> None:
|
||
# |150k-100k|/100k = 0.5 → 1-0.5 = 0.5.
|
||
assert _price_similarity(150_000.0, 100_000.0) == pytest.approx(0.5)
|
||
|
||
def test_fifty_pct_below_symmetric(self) -> None:
|
||
assert _price_similarity(50_000.0, 100_000.0) == pytest.approx(0.5)
|
||
|
||
def test_double_or_more_clamps_to_zero(self) -> None:
|
||
# +100% и больше → clamp 0 (не отрицательное).
|
||
assert _price_similarity(200_000.0, 100_000.0) == 0.0
|
||
assert _price_similarity(500_000.0, 100_000.0) == 0.0
|
||
|
||
def test_none_competitor_price_neutral(self) -> None:
|
||
assert _price_similarity(None, 100_000.0) == _NEUTRAL
|
||
|
||
def test_none_or_zero_median_neutral(self) -> None:
|
||
assert _price_similarity(100_000.0, None) == _NEUTRAL
|
||
assert _price_similarity(100_000.0, 0.0) == _NEUTRAL
|
||
|
||
|
||
# ── _stage_at_horizon (horizon-aware ключевой фактор) ──────────────────────────
|
||
|
||
|
||
class TestStageAtHorizon:
|
||
def test_projected_sold_out_is_low(self) -> None:
|
||
# 10 доступно, velocity 5/мес × 12 = 60 → остаток 0 → score 0.
|
||
assert _stage_at_horizon(10.0, 5.0, 12, 100.0) == 0.0
|
||
|
||
def test_active_competitor_high(self) -> None:
|
||
# 80 доступно, velocity 1/мес × 12 = 12 → остаток 68 из 100 → 0.68.
|
||
assert _stage_at_horizon(80.0, 1.0, 12, 100.0) == pytest.approx(0.68)
|
||
|
||
def test_partial_burn_down(self) -> None:
|
||
# 60 доступно, velocity 2/мес × 12 = 24 → остаток 36 из 100 → 0.36.
|
||
assert _stage_at_horizon(60.0, 2.0, 12, 100.0) == pytest.approx(0.36)
|
||
|
||
def test_zero_velocity_keeps_inventory(self) -> None:
|
||
# Нет продаж → остаток не уменьшается → доля доступного (50/100=0.5).
|
||
assert _stage_at_horizon(50.0, 0.0, 12, 100.0) == pytest.approx(0.5)
|
||
|
||
def test_thin_data_velocity_none_neutral(self) -> None:
|
||
assert _stage_at_horizon(50.0, None, 12, 100.0) == _NEUTRAL
|
||
|
||
def test_thin_data_available_none_neutral(self) -> None:
|
||
assert _stage_at_horizon(None, 5.0, 12, 100.0) == _NEUTRAL
|
||
|
||
def test_thin_data_total_none_neutral(self) -> None:
|
||
assert _stage_at_horizon(50.0, 5.0, 12, None) == _NEUTRAL
|
||
|
||
def test_zero_total_neutral_no_crash(self) -> None:
|
||
assert _stage_at_horizon(0.0, 5.0, 12, 0.0) == _NEUTRAL
|
||
|
||
def test_horizon_affects_projection(self) -> None:
|
||
# Тот же конкурент: короткий горизонт → больше остаток → выше score.
|
||
short = _stage_at_horizon(60.0, 2.0, 6, 100.0) # 60-12=48 → 0.48
|
||
long = _stage_at_horizon(60.0, 2.0, 24, 100.0) # 60-48=12 → 0.12
|
||
assert short > long
|
||
assert short == pytest.approx(0.48)
|
||
assert long == pytest.approx(0.12)
|
||
|
||
def test_remaining_clamped_non_negative(self) -> None:
|
||
# velocity огромная → projected_remaining clamp 0 (не отрицательный score).
|
||
assert _stage_at_horizon(5.0, 100.0, 12, 100.0) == 0.0
|
||
|
||
|
||
# ── _relevance_weight (взвешенная комбинация) ──────────────────────────────────
|
||
|
||
|
||
class TestRelevanceWeight:
|
||
def test_all_ones(self) -> None:
|
||
assert _relevance_weight(1.0, 1.0, 1.0, 1.0) == pytest.approx(1.0)
|
||
|
||
def test_all_zeros(self) -> None:
|
||
assert _relevance_weight(0.0, 0.0, 0.0, 0.0) == 0.0
|
||
|
||
def test_geo_only_equals_geo_weight(self) -> None:
|
||
assert _relevance_weight(1.0, 0.0, 0.0, 0.0) == pytest.approx(_W_GEO)
|
||
|
||
def test_stage_only_equals_stage_weight(self) -> None:
|
||
assert _relevance_weight(0.0, 0.0, 0.0, 1.0) == pytest.approx(_W_STAGE)
|
||
|
||
def test_weighted_mix(self) -> None:
|
||
# geo 0.30*1 + class 0.25*0.6 + price 0.20*0.5 + stage 0.25*0 = 0.55.
|
||
expected = _W_GEO * 1.0 + _W_CLASS * 0.6 + _W_PRICE * 0.5 + _W_STAGE * 0.0
|
||
assert _relevance_weight(1.0, 0.6, 0.5, 0.0) == pytest.approx(expected)
|
||
|
||
def test_result_clamped(self) -> None:
|
||
# Даже при «грязных» входах результат остаётся в [0,1].
|
||
assert 0.0 <= _relevance_weight(2.0, 2.0, 2.0, 2.0) <= 1.0
|
||
assert _relevance_weight(2.0, 2.0, 2.0, 2.0) == 1.0
|
||
|
||
def test_neutral_all_subscores_gives_neutral(self) -> None:
|
||
# Полностью тонкие данные (все 0.5) → итог 0.5 (не штраф, не завышение).
|
||
assert _relevance_weight(_NEUTRAL, _NEUTRAL, _NEUTRAL, _NEUTRAL) == pytest.approx(0.5)
|
||
|
||
|
||
# ── _dominant_class / _median ──────────────────────────────────────────────────
|
||
|
||
|
||
class TestDominantClass:
|
||
def test_most_common(self) -> None:
|
||
assert _dominant_class(["комфорт", "комфорт", "бизнес"]) == "комфорт"
|
||
|
||
def test_mixed_languages_counted_together(self) -> None:
|
||
# comfort + комфорт = один нормализованный класс → доминирует.
|
||
assert _dominant_class(["comfort", "комфорт", "бизнес"]) == "комфорт"
|
||
|
||
def test_ignores_unknown_and_none(self) -> None:
|
||
assert _dominant_class([None, "ерунда", "бизнес"]) == "бизнес"
|
||
|
||
def test_all_unknown_returns_none(self) -> None:
|
||
assert _dominant_class([None, "ерунда", ""]) is None
|
||
|
||
def test_empty_returns_none(self) -> None:
|
||
assert _dominant_class([]) is None
|
||
|
||
def test_tie_breaks_by_lowest_order_deterministic(self) -> None:
|
||
# Ничья эконом(0) vs бизнес(3) → меньший order-индекс (эконом).
|
||
assert _dominant_class(["эконом", "бизнес"]) == "эконом"
|
||
# Порядок входа не влияет — детерминированно.
|
||
assert _dominant_class(["бизнес", "эконом"]) == "эконом"
|
||
|
||
|
||
class TestMedian:
|
||
def test_odd(self) -> None:
|
||
assert _median([3.0, 1.0, 2.0]) == 2.0
|
||
|
||
def test_even(self) -> None:
|
||
assert _median([1.0, 2.0, 3.0, 4.0]) == pytest.approx(2.5)
|
||
|
||
def test_single(self) -> None:
|
||
assert _median([42.0]) == 42.0
|
||
|
||
def test_empty_none(self) -> None:
|
||
assert _median([]) is None
|
||
|
||
|
||
# ── class-order карта (документируется в отчёте) ───────────────────────────────
|
||
|
||
|
||
def test_class_order_is_monotonic_ladder() -> None:
|
||
"""эконом < комфорт < комфорт+ < бизнес < бизнес+ < премиум."""
|
||
assert _CLASS_ORDER["эконом"] < _CLASS_ORDER["комфорт"] < _CLASS_ORDER["комфорт+"]
|
||
assert _CLASS_ORDER["комфорт+"] < _CLASS_ORDER["бизнес"] < _CLASS_ORDER["бизнес+"]
|
||
assert _CLASS_ORDER["бизнес+"] < _CLASS_ORDER["премиум"]
|