gendesign/tradein-mvp/backend/tests/test_estimator_enrich.py
lekss361 758d7c6bf8
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m36s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Has been cancelled
fix(tradein/estimator): честная маркировка quarter-precision дат ДКП + insufficient_data флаг для sell-time-sensitivity (#1995) (#2355)
2026-07-04 00:14:02 +00:00

272 lines
9.4 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.

"""Unit tests for the web-features enrichment of the estimate response.
Covers the ADDITIVE + OPTIONAL fields surfaced for the new web views (map /
comp distribution / price↔exposure / ppm² trend):
- estimator._listing_to_analog / _deal_to_analog — now carry lat/lon
- estimator._fetch_price_trend — monthly ppm² series shape (mock db)
- estimator._compute_last_scraped_at — absolute freshness timestamp
No real DB / network: the trend test injects a fake Session whose execute()
returns canned mapping rows.
NOTE: importing app.services.estimator pulls app.core.config.Settings, which
requires DATABASE_URL — set BEFORE importing app modules (same pattern as the
sibling estimator unit tests).
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime
import pytest
from app.schemas.trade_in import AnalogLot, PriceTrendPoint
from app.services import estimator
# --------------------------------------------------------------------------- #
# Per-comp coords on analogs / deals (map + exposure views)
# --------------------------------------------------------------------------- #
def test_listing_to_analog_carries_lat_lon() -> None:
row = {
"address": "ул. Тестовая, 1",
"area_m2": 50.0,
"rooms": 2,
"floor": 3,
"total_floors": 9,
"price_rub": 10_000_000,
"price_per_m2": 200_000,
"listing_date": None,
"days_on_market": 14,
"photo_urls": None,
"source": "cian",
"source_url": "https://example.test/1",
"distance_m": 120.0,
"lat": 56.8389,
"lon": 60.6057,
}
lot = estimator._listing_to_analog(row)
assert isinstance(lot, AnalogLot)
assert lot.lat == pytest.approx(56.8389)
assert lot.lon == pytest.approx(60.6057)
# exposure + comp-distribution keys still present
assert lot.days_on_market == 14
assert lot.price_per_m2 == 200_000
assert lot.area_m2 == 50.0
assert lot.rooms == 2
def test_deal_to_analog_carries_lat_lon() -> None:
row = {
"address": "ул. Тестовая, 2",
"area_m2": 60.0,
"rooms": 2,
"floor": 5,
"total_floors": 10,
"price_rub": 11_000_000,
"price_per_m2": 183_333,
"deal_date": None,
"days_on_market": None,
"cadastral_number": "66:41:0204016:10",
"source": "rosreestr",
"distance_m": 80.0,
"lat": 56.84,
"lon": 60.61,
}
lot = estimator._deal_to_analog(row)
assert lot.lat == pytest.approx(56.84)
assert lot.lon == pytest.approx(60.61)
assert lot.tier == "T0_per_house" # kadastr with участок → per-house tier
# --------------------------------------------------------------------------- #
# date_precision honesty flag (#1995) — rosreestr deals are quarter-granular,
# real listings are day-granular. See _date_precision_for_source docstring +
# data/sql/01_schema_rosreestr_deals.sql for the underlying data-source reason.
# --------------------------------------------------------------------------- #
def test_deal_to_analog_marks_rosreestr_source_as_quarter_precision() -> None:
"""#1995: deals.source='rosreestr' → date_precision='quarter' — deal_date is
period_start_date (первый день квартала), НЕ реальная дата регистрации.
Это честная маркировка данных, а не баг (live-аудит prod: 9 кварталов,
ровно 1 distinct deal_date на квартал)."""
row = {
"address": "ул. Тестовая, 2",
"area_m2": 60.0,
"rooms": 2,
"floor": 5,
"total_floors": 10,
"price_rub": 11_000_000,
"price_per_m2": 183_333,
"deal_date": None,
"days_on_market": None,
"cadastral_number": "66:41:0204016:10",
"source": "rosreestr",
"distance_m": 80.0,
"lat": 56.84,
"lon": 60.61,
}
lot = estimator._deal_to_analog(row)
assert lot.date_precision == "quarter"
def test_listing_to_analog_marks_real_source_as_day_precision() -> None:
"""Listings (avito/cian/yandex/domklik) carry a real scrape/parse date →
date_precision='day'."""
row = {
"address": "ул. Тестовая, 1",
"area_m2": 50.0,
"rooms": 2,
"floor": 3,
"total_floors": 9,
"price_rub": 10_000_000,
"price_per_m2": 200_000,
"listing_date": None,
"days_on_market": 14,
"photo_urls": None,
"source": "cian",
"source_url": "https://example.test/1",
"distance_m": 120.0,
"lat": 56.8389,
"lon": 60.6057,
}
lot = estimator._listing_to_analog(row)
assert lot.date_precision == "day"
def test_date_precision_none_when_source_unknown() -> None:
"""source отсутствует (устаревшая/неполная строка) → date_precision=None —
не заявляем precision, которую не можем подтвердить."""
assert estimator._date_precision_for_source(None) is None
def test_analog_lat_lon_optional_none_when_missing() -> None:
# Tier S address-only Avito lots can lack geom → lat/lon absent → None (graceful).
row = {
"address": "ул. Без Координат, 3",
"area_m2": 40.0,
"rooms": 1,
"floor": None,
"total_floors": None,
"price_rub": 8_000_000,
"price_per_m2": 200_000,
"listing_date": None,
"days_on_market": None,
"photo_urls": None,
"source": "avito",
"source_url": None,
"distance_m": None,
}
lot = estimator._listing_to_analog(row)
assert lot.lat is None
assert lot.lon is None
# --------------------------------------------------------------------------- #
# _compute_last_scraped_at
# --------------------------------------------------------------------------- #
def test_last_scraped_at_returns_max_timestamp() -> None:
older = datetime(2026, 5, 1, 10, 0, tzinfo=UTC)
newer = datetime(2026, 5, 28, 9, 30, tzinfo=UTC)
lots = [{"scraped_at": older}, {"scraped_at": newer}]
assert estimator._compute_last_scraped_at(lots) == newer
def test_last_scraped_at_empty_returns_none() -> None:
assert estimator._compute_last_scraped_at([]) is None
# --------------------------------------------------------------------------- #
# _fetch_price_trend (mock db)
# --------------------------------------------------------------------------- #
class _FakeResult:
def __init__(self, rows: list[dict]) -> None:
self._rows = rows
def mappings(self) -> "_FakeResult":
return self
def all(self) -> list[dict]:
return self._rows
class _FakeSession:
"""Minimal Session stub: returns canned rows per execute() call in order."""
def __init__(self, *results: list[dict]) -> None:
self._results = list(results)
self.calls = 0
def execute(self, *_args, **_kwargs) -> _FakeResult:
rows = self._results[self.calls] if self.calls < len(self._results) else []
self.calls += 1
return _FakeResult(rows)
def rollback(self) -> None: # pragma: no cover — not exercised on happy path
pass
def test_fetch_price_trend_none_when_no_house_id() -> None:
db = _FakeSession()
assert estimator._fetch_price_trend(db, target_house_id=None) is None
assert db.calls == 0 # short-circuits before any query
def test_fetch_price_trend_prefers_houses_price_dynamics() -> None:
hpd_rows = [
{"month": "2025-01", "ppm2": 200_000},
{"month": "2025-02", "ppm2": 205_000},
{"month": "2025-03", "ppm2": 210_000},
]
db = _FakeSession(hpd_rows) # first query hits → no fallback needed
trend = estimator._fetch_price_trend(db, target_house_id=123)
assert trend is not None
assert db.calls == 1 # did NOT touch the fallback source
assert trend == [
{"month": "2025-01", "ppm2": 200_000},
{"month": "2025-02", "ppm2": 205_000},
{"month": "2025-03", "ppm2": 210_000},
]
# Shape contract: each point validates as PriceTrendPoint(month:str, ppm2:int).
points = [PriceTrendPoint(**p) for p in trend]
assert all(isinstance(p.month, str) and isinstance(p.ppm2, int) for p in points)
def test_fetch_price_trend_falls_back_to_placement_history() -> None:
fallback_rows = [
{"month": "2024-06", "ppm2": 190_000},
{"month": "2024-07", "ppm2": 195_000},
{"month": "2024-08", "ppm2": 198_000},
]
# First query (houses_price_dynamics) empty → second (placement_history) hits.
db = _FakeSession([], fallback_rows)
trend = estimator._fetch_price_trend(db, target_house_id=235340)
assert trend == fallback_rows
assert db.calls == 2
def test_fetch_price_trend_none_when_below_min_points() -> None:
# Both sources return < min_points (3) → None (graceful, frontend hides chart).
db = _FakeSession([{"month": "2025-01", "ppm2": 200_000}], [])
assert estimator._fetch_price_trend(db, target_house_id=999) is None
def test_price_trend_point_shape() -> None:
p = PriceTrendPoint(month="2026-05", ppm2=251_429)
assert p.month == "2026-05"
assert p.ppm2 == 251_429
dumped = p.model_dump()
assert set(dumped) == {"month", "ppm2"}
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))