gendesign/backend/tests/services/site_finder/test_competitor_relevance.py
Light1YT 59be55f80e
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-worker (push) Successful in 2m34s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m26s
Deploy / deploy (push) Successful in 1m10s
feat(site-finder): per-competitor relevance_weight (#949 PR B) (#1000)
2026-06-02 19:45:38 +00:00

306 lines
14 KiB
Python
Raw 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.

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