gendesign/tradein-mvp/backend/tests/test_estimator_kitchen_ceiling_2012.py
lekss361 480d4c2128
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
fix(tradein/estimator): kitchen_area_m2/ceiling_height_m/is_apartments comp-scoring (#2012) (#2396)
2026-07-04 08:33:17 +00:00

248 lines
11 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.

"""#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"]))