"""Unit tests for #651 IMV blend (killer accuracy fix) + #652 DKP corridor. Тестируем чистую blend-трансформацию `_apply_imv_blend` (без БД) и DB-helpers с замоканной Session — premium-blend поднимает медиану + расширяет диапазон, no-anchor / sub-threshold случаи — no-op. """ import os # Settings требует DATABASE_URL при инициализации (fail-fast, C-3). os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") from unittest.mock import MagicMock from app.services.estimator import ( _apply_imv_blend, _fetch_dkp_corridor, _fetch_house_imv_anchor, ) # ── #651: pure blend transform ──────────────────────────────────────────────── def test_blend_premium_raises_median_and_extends_range() -> None: """IMV ≫ median → median поднимается blend'ом, range_high расширяется.""" # median 50М, IMV recommended 100М (≈2x — premium/view unit underestimate), # IMV higher 110М. weight 0.5, threshold 1.15. area = 80.0 median_price = 50_000_000 range_high = 60_000_000 median_ppm2 = median_price / area new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( median_price=median_price, range_high=range_high, median_ppm2=median_ppm2, area=area, anchor_total=100_000_000, anchor_higher=110_000_000, weight=0.5, threshold=1.15, ) assert blended is True # blend = 50М*0.5 + 100М*0.5 = 75М assert new_median == 75_000_000 # range_high расширен до IMV higher (110М) assert new_range_high == 110_000_000 # ppm2 пересчитан консистентно assert new_ppm2 == 75_000_000 / area assert anchor_used == 100_000_000 def test_blend_no_op_when_anchor_below_median() -> None: """A < median → медиану НЕ понижаем (однонаправленность), но диапазон может расшириться.""" area = 50.0 median_price = 10_000_000 range_high = 11_000_000 median_ppm2 = median_price / area new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( median_price=median_price, range_high=range_high, median_ppm2=median_ppm2, area=area, anchor_total=8_000_000, # ниже медианы anchor_higher=8_500_000, weight=0.5, threshold=1.15, ) assert blended is False assert new_median == median_price # не понижаем assert new_ppm2 == median_ppm2 assert anchor_used == 8_000_000 # range_high не сужается (anchor ниже текущего high) assert new_range_high == range_high def test_blend_no_op_below_threshold() -> None: """A выше median, но в пределах threshold → blend не срабатывает (медиана та же).""" area = 40.0 median_price = 10_000_000 range_high = 11_000_000 median_ppm2 = median_price / area new_median, _, new_ppm2, blended, _ = _apply_imv_blend( median_price=median_price, range_high=range_high, median_ppm2=median_ppm2, area=area, anchor_total=11_000_000, # ×1.1 < threshold 1.15 anchor_higher=None, weight=0.5, threshold=1.15, ) assert blended is False assert new_median == median_price assert new_ppm2 == median_ppm2 def test_blend_no_op_when_no_anchor() -> None: """anchor_total=None → graceful no-op (common case без IMV-данных).""" new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( median_price=12_000_000, range_high=14_000_000, median_ppm2=200_000.0, area=60.0, anchor_total=None, anchor_higher=None, weight=0.5, threshold=1.15, ) assert blended is False assert anchor_used is None assert new_median == 12_000_000 assert new_range_high == 14_000_000 assert new_ppm2 == 200_000.0 def test_blend_extends_range_to_anchor_when_no_higher() -> None: """Нет higher_price → диапазон расширяется до самого якоря.""" _, new_range_high, _, blended, _ = _apply_imv_blend( median_price=50_000_000, range_high=60_000_000, median_ppm2=625_000.0, area=80.0, anchor_total=100_000_000, anchor_higher=None, weight=0.5, threshold=1.15, ) assert blended is True assert new_range_high == 100_000_000 # до якоря, т.к. higher отсутствует # ── #651: house_imv anchor lookup (mocked Session) ───────────────────────────── def test_fetch_house_imv_anchor_none_when_no_house_id() -> None: """target_house_id=None → None без обращения к БД.""" mock_db = MagicMock() assert _fetch_house_imv_anchor(mock_db, target_house_id=None, rooms=2, area=50.0) is None mock_db.execute.assert_not_called() def test_fetch_house_imv_anchor_returns_row() -> None: """Возвращает dict из house_imv_evaluations при наличии строки.""" mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.first.return_value = { "recommended_price": 31_950_000, "lower_price": 30_352_500, "higher_price": 33_547_500, "market_count": 800, "rooms": 2, "area_m2": 80.0, } res = _fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=2, area=80.0) assert res is not None assert res["recommended_price"] == 31_950_000 assert res["higher_price"] == 33_547_500 # ── #652: DKP corridor (mocked Session) ──────────────────────────────────────── def test_dkp_corridor_none_without_street() -> None: """Адрес без распознаваемой улицы → None.""" mock_db = MagicMock() assert _fetch_dkp_corridor(mock_db, address="", rooms=2, area=50.0) is None def test_dkp_corridor_aggregates_ppm2() -> None: """Считает low/median/high ₽/м² по сделкам.""" mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.all.return_value = [ {"price_per_m2": 100_000}, {"price_per_m2": 120_000}, {"price_per_m2": 140_000}, ] res = _fetch_dkp_corridor( mock_db, address="улица Ленина, 5", rooms=2, area=50.0 ) assert res is not None assert res["count"] == 3 assert res["low_ppm2"] == 100_000 assert res["median_ppm2"] == 120_000 assert res["high_ppm2"] == 140_000