"""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["премиум"]