All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m33s
Deploy Trade-In / build-backend (push) Successful in 55s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 46s
248 lines
11 KiB
Python
248 lines
11 KiB
Python
"""#2012 — kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring.
|
||
|
||
Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
|
||
estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
|
||
ceiling_height_m / is_apartments НЕ читал вовсе для отбора/скоринга аналогов.
|
||
|
||
Три независимых флага, все default OFF:
|
||
- estimate_kitchen_area_signal_enabled / estimate_ceiling_height_signal_enabled
|
||
("мягкие корректировки") — pure-Python self-referential pool-median
|
||
deviation penalty (см. _adjust_relevance_by_pool_deviation). НЕТ target-
|
||
значения для сравнения (ни TradeInEstimateInput, ни `deals` его не несут),
|
||
поэтому — в отличие от house_type/year_built — штраф считается от МЕДИАНЫ
|
||
ПУЛА кандидатов, а не от target.
|
||
- estimate_is_apartments_filter_enabled — hard-filter в _COMMON_WHERE (+ Tier W
|
||
inline copy), симметричный novostroyki-guard #1186. SQL-фрагмент проверяется
|
||
на сгенерированном тексте (mock db, паттерн test_estimator_radius_dedup_1871.py)
|
||
— полный radius-путь требует PostGIS+БД.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import pytest
|
||
|
||
import app.services.estimator as est
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# _adjust_relevance_by_pool_deviation — pure, no DB
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _cands(values: list[float | None], key: str = "kitchen_area_m2") -> list[dict]:
|
||
return [{key: v, "relevance_score": 0.0} for v in values]
|
||
|
||
|
||
def test_pool_deviation_null_safe_missing_key_not_penalized_or_counted() -> None:
|
||
# 5 candidates carry a value (>= min_n), 2 don't -> those 2 stay untouched.
|
||
cands = _cands([9.0, 9.0, 9.0, 9.0, 9.0, None, None])
|
||
est._adjust_relevance_by_pool_deviation(
|
||
cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
|
||
)
|
||
for c in cands[:5]:
|
||
assert c["relevance_score"] == 0.0 # at the pool median -> no penalty
|
||
for c in cands[5:]:
|
||
assert c["relevance_score"] == 0.0 # missing value -> untouched, not 0-diff
|
||
|
||
|
||
def test_pool_deviation_sparse_safe_skips_when_below_min_n() -> None:
|
||
# Only 3 candidates carry a value, min_n=5 -> signal skipped for the WHOLE pool.
|
||
cands = _cands([5.0, 20.0, 5.0])
|
||
est._adjust_relevance_by_pool_deviation(
|
||
cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
|
||
)
|
||
assert all(c["relevance_score"] == 0.0 for c in cands)
|
||
|
||
|
||
def test_pool_deviation_penalizes_far_from_median() -> None:
|
||
# Median of [8, 9, 9, 9, 10] = 9. Deviant 20 -> penalty = |20-9|/3.0 = 3.667,
|
||
# clamped to max_penalty=1.0.
|
||
cands = _cands([8.0, 9.0, 9.0, 9.0, 10.0, 20.0])
|
||
est._adjust_relevance_by_pool_deviation(
|
||
cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
|
||
)
|
||
assert cands[0]["relevance_score"] == pytest.approx(1.0 / 3.0) # |8-9|/3
|
||
assert cands[1]["relevance_score"] == 0.0 # at median
|
||
assert cands[-1]["relevance_score"] == 1.0 # clamped
|
||
|
||
|
||
def test_pool_deviation_max_penalty_clamp() -> None:
|
||
cands = _cands([1.0, 1.0, 1.0, 1.0, 1.0, 100.0])
|
||
est._adjust_relevance_by_pool_deviation(
|
||
cands, key="kitchen_area_m2", scale=1.0, max_penalty=0.5, min_n=5
|
||
)
|
||
assert cands[-1]["relevance_score"] == 0.5
|
||
|
||
|
||
def test_pool_deviation_additive_not_overwriting_existing_score() -> None:
|
||
"""Mutation ADDS to relevance_score (e.g. house_type SQL penalty already there),
|
||
it never overwrites it."""
|
||
cands = [
|
||
{"kitchen_area_m2": 9.0, "relevance_score": 1.5}, # e.g. house_type mismatch
|
||
{"kitchen_area_m2": 9.0, "relevance_score": 1.5},
|
||
{"kitchen_area_m2": 9.0, "relevance_score": 0.0},
|
||
{"kitchen_area_m2": 9.0, "relevance_score": 0.0},
|
||
{"kitchen_area_m2": 20.0, "relevance_score": 0.0},
|
||
]
|
||
est._adjust_relevance_by_pool_deviation(
|
||
cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
|
||
)
|
||
assert cands[0]["relevance_score"] == pytest.approx(1.5) # at median, +0
|
||
assert cands[-1]["relevance_score"] == pytest.approx(1.0) # median=9, |20-9|/3 clamped to 1.0
|
||
|
||
|
||
def test_pool_deviation_missing_relevance_score_key_defaults_to_zero() -> None:
|
||
cands = [{"kitchen_area_m2": v} for v in [8.0, 9.0, 9.0, 9.0, 10.0]]
|
||
est._adjust_relevance_by_pool_deviation(
|
||
cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
|
||
)
|
||
assert all("relevance_score" in c for c in cands)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# _apply_kitchen_ceiling_signal — flag wiring
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _mixed_pool() -> list[dict]:
|
||
return [
|
||
{"kitchen_area_m2": 8.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
|
||
{"kitchen_area_m2": 9.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
|
||
{"kitchen_area_m2": 9.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
|
||
{"kitchen_area_m2": 9.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
|
||
{"kitchen_area_m2": 20.0, "ceiling_height_m": 4.5, "relevance_score": 0.0},
|
||
]
|
||
|
||
|
||
def test_apply_signal_noop_when_both_flags_off() -> None:
|
||
cands = _mixed_pool()
|
||
with (
|
||
patch.object(est.settings, "estimate_kitchen_area_signal_enabled", False),
|
||
patch.object(est.settings, "estimate_ceiling_height_signal_enabled", False),
|
||
):
|
||
est._apply_kitchen_ceiling_signal(cands)
|
||
assert all(c["relevance_score"] == 0.0 for c in cands)
|
||
|
||
|
||
def test_apply_signal_kitchen_only() -> None:
|
||
cands = _mixed_pool()
|
||
with (
|
||
patch.object(est.settings, "estimate_kitchen_area_signal_enabled", True),
|
||
patch.object(est.settings, "estimate_ceiling_height_signal_enabled", False),
|
||
):
|
||
est._apply_kitchen_ceiling_signal(cands)
|
||
assert cands[-1]["relevance_score"] > 0.0 # kitchen outlier (20) penalized
|
||
for c in cands[1:4]:
|
||
assert c["relevance_score"] == 0.0 # at pool median (9.0) -> untouched
|
||
|
||
|
||
def test_apply_signal_ceiling_only() -> None:
|
||
cands = _mixed_pool()
|
||
with (
|
||
patch.object(est.settings, "estimate_kitchen_area_signal_enabled", False),
|
||
patch.object(est.settings, "estimate_ceiling_height_signal_enabled", True),
|
||
):
|
||
est._apply_kitchen_ceiling_signal(cands)
|
||
assert cands[-1]["relevance_score"] > 0.0 # ceiling outlier penalized
|
||
for c in cands[:4]:
|
||
assert c["relevance_score"] == 0.0
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Defaults
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def test_new_flags_default_off() -> None:
|
||
from app.core.config import settings
|
||
|
||
assert settings.estimate_kitchen_area_signal_enabled is False
|
||
assert settings.estimate_ceiling_height_signal_enabled is False
|
||
assert settings.estimate_is_apartments_filter_enabled is False
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# is_apartments hard-filter — SQL-fragment (mock db, no PostGIS/DB needed)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _capture_tier_sql_and_params(*, is_apartments_filter: bool) -> list[tuple[str, dict]]:
|
||
"""Runs _fetch_analogs with a mock db, returns (sql_text, params) per tier.
|
||
|
||
target_house_id + short_addr + year/floors are all set so SQL for ALL four
|
||
tiers renders (each tier returns [] -> fallthrough to the next).
|
||
"""
|
||
captured: list[tuple[str, dict]] = []
|
||
db = MagicMock()
|
||
|
||
def side_effect(*args, **kwargs): # type: ignore[no-untyped-def]
|
||
params = args[1] if len(args) > 1 else {}
|
||
captured.append((str(args[0].text), dict(params)))
|
||
result = MagicMock()
|
||
result.mappings.return_value.all.return_value = []
|
||
return result
|
||
|
||
db.execute.side_effect = side_effect
|
||
|
||
with patch.object(est.settings, "estimate_is_apartments_filter_enabled", is_apartments_filter):
|
||
est._fetch_analogs(
|
||
db,
|
||
lat=56.83,
|
||
lon=60.6,
|
||
rooms=2,
|
||
area=50.0,
|
||
radius_m=2000,
|
||
full_address="г Екатеринбург, ул Малышева, д 30",
|
||
year_built=2010,
|
||
house_type="монолит",
|
||
total_floors=20,
|
||
target_house_id=123,
|
||
)
|
||
return captured
|
||
|
||
|
||
def test_all_four_tiers_render_with_is_apartments_guard() -> None:
|
||
calls = _capture_tier_sql_and_params(is_apartments_filter=False)
|
||
assert len(calls) == 4, "ожидаем S-canonical, S-fallback, H, W"
|
||
|
||
|
||
def test_is_apartments_bind_param_present_in_every_tier() -> None:
|
||
calls = _capture_tier_sql_and_params(is_apartments_filter=False)
|
||
for i, (sql, params) in enumerate(calls):
|
||
assert ":is_apartments_filter" in sql, f"tier#{i} без is_apartments-гварда"
|
||
assert "is_apartments_filter" in params, f"tier#{i}: параметр не передан"
|
||
|
||
|
||
def test_is_apartments_null_safe_guard_sql_present() -> None:
|
||
calls = _capture_tier_sql_and_params(is_apartments_filter=True)
|
||
for i, (sql, _params) in enumerate(calls):
|
||
assert "IS NOT TRUE" in sql, f"tier#{i}: гейт по флагу отсутствует"
|
||
assert "is_apartments IS NULL" in sql, f"tier#{i}: NULL-safe пропуск отсутствует"
|
||
assert "is_apartments = false" in sql, f"tier#{i}: явный False-пропуск отсутствует"
|
||
|
||
|
||
def test_is_apartments_param_value_reflects_setting() -> None:
|
||
calls_off = _capture_tier_sql_and_params(is_apartments_filter=False)
|
||
for i, (_sql, params) in enumerate(calls_off):
|
||
assert params["is_apartments_filter"] is False, f"tier#{i}"
|
||
|
||
calls_on = _capture_tier_sql_and_params(is_apartments_filter=True)
|
||
for i, (_sql, params) in enumerate(calls_on):
|
||
assert params["is_apartments_filter"] is True, f"tier#{i}"
|
||
|
||
|
||
def test_no_bind_param_double_colon_cast() -> None:
|
||
"""psycopg3-инвариант: только column::type (не :bind::type)."""
|
||
import re
|
||
|
||
bad = re.compile(r":[a-z_]+::[a-z]")
|
||
for i, (sql, _params) in enumerate(_capture_tier_sql_and_params(is_apartments_filter=True)):
|
||
assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"
|
||
|
||
|
||
if __name__ == "__main__": # pragma: no cover
|
||
raise SystemExit(pytest.main([__file__, "-q"]))
|