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