gendesign/tradein-mvp/backend/tests/test_estimator_premium_building.py
lekss361 e39fb86a9b
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 1m29s
Deploy Trade-In / deploy (push) Successful in 52s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 56s
feat(tradein): AI-curated premium-building overlay — class + false-positive demotion (#2002)
2026-06-27 18:43:05 +00:00

202 lines
8.1 KiB
Python
Raw Permalink 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.

"""#2002: premium_building флаг + класс из curated overlay → MV `premium_houses`.
Покрывает read-only helper `_is_premium_building`, который возвращает 3-кортеж
`(is_premium, med_ppm2, building_class)` из двухслойного overlay:
1. `premium_buildings_curated` (data/sql/142) — AI/human-выверенный слой (класс +
is_premium с false-positive demotions); ПЕРЕБИВАЕТ MV;
2. `premium_houses` MV (data/sql/139) — даёт med_ppm2 + fallback-членство.
Сценарии:
- curated premium=True с классом → (True, MV.med_ppm2, tier);
- curated premium=False демотит дом из MV → (False, med, tier) — false-positive;
- невыверенный дом в MV → (True, med, None) — членство есть, класс неизвестен;
- дом нигде → (False, None, None);
- house_id is None → (False, None, None) без обращения к БД;
- таблица отсутствует / SQL-ошибка → graceful (False, None, None), оценка не падает.
Плюс smoke-проверка дефолтов в response-модели AggregatedEstimate (флаг — метаданные,
сериализация без изменений, когда дом не премиальный).
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import pytest
from app.services.estimator import _is_premium_building
def _make_mock_db(
*, curated_row: Any = None, mv_row: Any = None, raises: bool = False
) -> MagicMock:
"""mock db.execute, маршрутизируя по SQL-тексту.
`SELECT ... FROM premium_buildings_curated ...` → curated_row;
`SELECT med_ppm2 FROM premium_houses ...` → mv_row.
raises=True имитирует отсутствие таблицы (первый же execute бросает).
"""
mock_db = MagicMock()
if raises:
mock_db.execute.side_effect = RuntimeError(
'relation "premium_buildings_curated" does not exist'
)
return mock_db
def _execute(sql: Any, params: Any = None) -> MagicMock:
text_sql = str(getattr(sql, "text", sql))
result = MagicMock()
if "premium_buildings_curated" in text_sql:
result.first.return_value = curated_row
elif "premium_houses" in text_sql:
result.first.return_value = mv_row
else: # pragma: no cover — defensive
result.first.return_value = None
return result
mock_db.execute.side_effect = _execute
return mock_db
def _executed_sql(db: MagicMock) -> list[str]:
"""Тексты всех SQL-запросов, прошедших через db.execute."""
return [str(call.args[0].text) for call in db.execute.call_args_list]
def test_curated_premium_with_class_overrides_mv() -> None:
"""curated is_premium=True + tier → (True, MV.med_ppm2, tier); класс из curated."""
db = _make_mock_db(curated_row=(True, "премиум"), mv_row=(422_000,))
assert _is_premium_building(db, 11370) == (True, 422_000, "премиум")
# обе таблицы используют CAST(:hid AS bigint) — psycopg v3, не :hid::bigint
sqls = _executed_sql(db)
assert any("premium_buildings_curated" in s for s in sqls)
assert any("premium_houses" in s for s in sqls)
for s in sqls:
assert "CAST(:hid AS bigint)" in s
assert "::bigint" not in s
def test_curated_false_positive_demotes_mv_member() -> None:
"""curated is_premium=False демотит дом-член MV → (False, med, tier).
Концентрационный MV пометил дом премиальным, но curation признал его
false-positive (mass-market). curated перебивает — флаг False, но med и класс
из данных сохраняются как контекст для ревьюера.
"""
db = _make_mock_db(curated_row=(False, "комфорт"), mv_row=(211_000,))
assert _is_premium_building(db, 9401) == (False, 211_000, "комфорт")
def test_curated_class_flows_when_mv_absent() -> None:
"""curated есть, дома нет в MV → (curated.is_premium, None, tier)."""
db = _make_mock_db(curated_row=(True, "бизнес"), mv_row=None)
assert _is_premium_building(db, 6685) == (True, None, "бизнес")
def test_uncurated_mv_member_premium_true_class_none() -> None:
"""Невыверенный дом, но член MV → (True, med, None) — класс неизвестен."""
db = _make_mock_db(curated_row=None, mv_row=(255_000,))
assert _is_premium_building(db, 4242) == (True, 255_000, None)
def test_not_premium_anywhere_yields_false_none_none() -> None:
"""Дом отсутствует и в curated, и в MV → (False, None, None)."""
db = _make_mock_db(curated_row=None, mv_row=None)
assert _is_premium_building(db, 9999) == (False, None, None)
def test_house_id_none_short_circuits_without_query() -> None:
"""house_id is None → (False, None, None), к БД не обращаемся."""
db = _make_mock_db(curated_row=(True, "премиум"), mv_row=(255_000,))
assert _is_premium_building(db, None) == (False, None, None)
db.execute.assert_not_called()
def test_missing_table_degrades_gracefully() -> None:
"""Таблица не существует / SQL-ошибка → (False, None, None), без проброса."""
db = _make_mock_db(raises=True)
assert _is_premium_building(db, 4242) == (False, None, None)
# после ошибки откатываем poisoned-транзакцию
db.rollback.assert_called_once()
def test_mv_median_null_is_handled() -> None:
"""Дом в MV, но med_ppm2 NULL → median None (флаг/класс не теряются)."""
db = _make_mock_db(curated_row=(True, "элит"), mv_row=(None,))
assert _is_premium_building(db, 7109) == (True, None, "элит")
def test_response_model_defaults_non_premium() -> None:
"""AggregatedEstimate по умолчанию: premium_building=False, median/class=None.
Гарантирует, что добавление полей не меняет сериализацию для не-премиум оценок.
"""
from datetime import UTC, datetime
from uuid import uuid4
from app.schemas.trade_in import AggregatedEstimate
est = AggregatedEstimate(
estimate_id=uuid4(),
median_price_rub=10_000_000,
range_low_rub=9_000_000,
range_high_rub=11_000_000,
median_price_per_m2=200_000,
confidence="medium",
n_analogs=12,
period_months=24,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC),
)
assert est.premium_building is False
assert est.premium_building_median_ppm2 is None
assert est.premium_building_class is None
dumped = est.model_dump()
assert dumped["premium_building"] is False
assert dumped["premium_building_median_ppm2"] is None
assert dumped["premium_building_class"] is None
def test_response_model_carries_curated_class() -> None:
"""premium_building_class сериализуется, когда задан (curated overlay)."""
from datetime import UTC, datetime
from uuid import uuid4
from app.schemas.trade_in import AggregatedEstimate
est = AggregatedEstimate(
estimate_id=uuid4(),
median_price_rub=42_000_000,
range_low_rub=40_000_000,
range_high_rub=44_000_000,
median_price_per_m2=420_000,
confidence="medium",
n_analogs=8,
period_months=24,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC),
premium_building=True,
premium_building_median_ppm2=422_000,
premium_building_class="премиум",
)
assert est.model_dump()["premium_building_class"] == "премиум"
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))