All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m36s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Successful in 1m15s
373 lines
16 KiB
Python
373 lines
16 KiB
Python
"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02).
|
||
|
||
Live-prod repro that motivated this gate: Серов 2к/45м², n=3 scraped listings →
|
||
headline 42 391 ₽/м² (−36% vs the city ДКП corridor, 54 126 ₽/м²); a neighbouring
|
||
street in the same town swung ±66% on 1-2 different random listings. Каменск-
|
||
Уральский returned a LITERAL 0 ₽ for a room/area combo with no local ДКП match
|
||
either, with no honest refusal surfaced. Первоуральск (0 listings) already fell
|
||
back to the (pre-existing) ДКП deals-headline fallback correctly — this gate
|
||
routes the THIN (1..HEADLINE_LISTINGS_MIN_N-1 listings) case into that SAME,
|
||
already-tested path instead of trusting a 1-4-lot median as the headline.
|
||
|
||
Two layers:
|
||
1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) —
|
||
boundary behaviour of the gate itself.
|
||
2. `estimate_quality` integration tests — proves the money-path invariants
|
||
that matter to a caller: literal 0 never leaks as a "confident" price,
|
||
display `analogs` cards never outnumber what `n_analogs` claims, and the
|
||
explanation text describes what actually happened (not a stock "аналогов
|
||
не найдено" when some WERE found, just too few).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import anyio
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services import estimator
|
||
from app.services.estimator import HEADLINE_LISTINGS_MIN_N, _price_from_inputs
|
||
from app.services.geocoder import GeocodeResult
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Layer 1 — `_price_from_inputs` direct unit tests
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _geo() -> GeocodeResult:
|
||
return GeocodeResult(
|
||
lat=59.604,
|
||
lon=60.577,
|
||
full_address="Свердловская обл., Серов, ул. Ленина, 5",
|
||
provider="nominatim",
|
||
)
|
||
|
||
|
||
def _lot(ppm2: float, address: str = "ул. Ленина, 5", source: str = "avito") -> dict[str, Any]:
|
||
return {"price_per_m2": ppm2, "address": address, "source": source}
|
||
|
||
|
||
def _lots(prices: list[float]) -> list[dict[str, Any]]:
|
||
return [_lot(p, address=f"ул. Ленина, {i + 5}") for i, p in enumerate(prices)]
|
||
|
||
|
||
def _call(
|
||
*,
|
||
listings: list[dict[str, Any]],
|
||
area_m2: float = 45.0,
|
||
rooms: int | None = 2,
|
||
dkp_raw: dict[str, Any] | None = None,
|
||
anchor_comps: list[dict[str, Any]] | None = None,
|
||
anchor_tier_fetched: str | None = None,
|
||
) -> estimator.PricingResult:
|
||
def ratio_resolver(_appm2: float | None) -> tuple[float | None, str | None]:
|
||
return None, None
|
||
|
||
return _price_from_inputs(
|
||
listings=listings,
|
||
area_m2=area_m2,
|
||
rooms=rooms,
|
||
repair_state=None,
|
||
floor=5,
|
||
total_floors=9,
|
||
target_year=None,
|
||
analog_tier="W",
|
||
fallback_used=False,
|
||
area_widened=False,
|
||
anchor_comps=anchor_comps or [],
|
||
anchor_tier_fetched=anchor_tier_fetched,
|
||
dkp_raw=dkp_raw,
|
||
imv_anchor=None,
|
||
imv_eval=None,
|
||
yandex_val_present=False,
|
||
cian_val_present=False,
|
||
ratio_resolver=ratio_resolver,
|
||
quarter_index_lookup=lambda q: None,
|
||
quarter_indexes_lookup=lambda qs: {},
|
||
target_house_cadnum=None,
|
||
dadata_coarse=False,
|
||
geo=_geo(),
|
||
dadata_qc_geo=None,
|
||
)
|
||
|
||
|
||
def test_threshold_is_five_not_lower() -> None:
|
||
"""The chosen sufficiency floor — see estimator.py module docstring (#oblast-E)
|
||
for the data-driven justification (n=3 live-repro'd −36%, n=5 matches the
|
||
existing MIN_ANALOGS_TIER_0 "enough to trust" convention)."""
|
||
assert HEADLINE_LISTINGS_MIN_N == 5
|
||
|
||
|
||
def test_four_listings_below_threshold_suppressed_no_fallback() -> None:
|
||
"""n=4 (< 5), no ДКП signal → headline suppressed to the honest zero state,
|
||
NOT the naive median of 4 listings."""
|
||
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0]))
|
||
assert pr.median_ppm2 == 0.0
|
||
assert pr.median_price == 0
|
||
assert pr.n_analogs == 0
|
||
assert pr.range_low == 0
|
||
assert pr.range_high == 0
|
||
|
||
|
||
def test_five_listings_at_threshold_not_suppressed() -> None:
|
||
"""n=5 (== threshold) → the real listings median is trusted as the headline."""
|
||
pr = _call(listings=_lots([200_000.0, 205_000.0, 210_000.0, 215_000.0, 220_000.0]))
|
||
assert pr.median_ppm2 == 210_000.0
|
||
assert pr.n_analogs == 5
|
||
assert pr.median_price == round(210_000.0 * 45.0)
|
||
|
||
|
||
def test_one_listing_below_threshold_suppressed() -> None:
|
||
"""n=1 — the sharpest form of the Серов bug (a single random lot deciding
|
||
the whole headline) — must be suppressed exactly like n=4."""
|
||
pr = _call(listings=_lots([200_000.0]))
|
||
assert pr.median_ppm2 == 0.0
|
||
assert pr.n_analogs == 0
|
||
|
||
|
||
def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None:
|
||
"""n=3 listings (thin) + a usable ДКП corridor → headline comes from the
|
||
deal corridor median, NOT the 3-listing median (live Серов repro: 3
|
||
listings gave 42 391 vs the honest ДКП-based ~54 126)."""
|
||
dkp_raw = {
|
||
"count": 54,
|
||
"low_ppm2": 44_000,
|
||
"median_ppm2": 65_957,
|
||
"high_ppm2": 89_000,
|
||
"period_months": 12,
|
||
}
|
||
pr = _call(
|
||
listings=_lots([42_391.0, 26_818.0, 75_058.0]),
|
||
dkp_raw=dkp_raw,
|
||
)
|
||
assert pr.median_ppm2 == 65_957.0, (
|
||
f"headline={pr.median_ppm2} must equal the ДКП corridor median, not the "
|
||
"noisy 3-listing median (42 391 area)"
|
||
)
|
||
assert pr.n_analogs == 0, "honest: 0 scraped-listing analogs back this headline"
|
||
assert pr.confidence == "low"
|
||
|
||
|
||
def test_thin_sample_with_insufficient_deals_stays_zero() -> None:
|
||
"""n=3 listings (thin) + a ДКП corridor that is ITSELF too thin
|
||
(< DEALS_HEADLINE_FALLBACK_MIN_N) → neither source is trusted; honest zero,
|
||
not a fabricated number from either side."""
|
||
dkp_raw = {
|
||
"count": 1,
|
||
"low_ppm2": 40_000,
|
||
"median_ppm2": 65_957,
|
||
"high_ppm2": 80_000,
|
||
"period_months": 12,
|
||
}
|
||
pr = _call(listings=_lots([42_391.0, 26_818.0, 75_058.0]), dkp_raw=dkp_raw)
|
||
assert pr.median_ppm2 == 0.0
|
||
assert pr.median_price == 0
|
||
assert pr.n_analogs == 0
|
||
|
||
|
||
def test_thin_sample_explanation_is_honest_about_count() -> None:
|
||
"""The explanation for a thin-but-nonzero sample must say HOW MANY listings
|
||
were found (not the generic 'ничего не найдено' text used for a genuine
|
||
zero-listing case) — #4 in the task: explanation must match reality."""
|
||
pr = _call(listings=_lots([200_000.0, 210_000.0])) # n=2
|
||
assert pr.explanation is not None
|
||
assert "2" in pr.explanation
|
||
assert "недостаточно" in pr.explanation.lower()
|
||
# Must NOT reuse the "nothing found at all" copy — 2 listings WERE found.
|
||
assert "не найдено аналогов" not in pr.explanation.lower()
|
||
|
||
|
||
def test_thin_sample_deals_fallback_explanation_does_not_claim_zero_listings() -> None:
|
||
"""#4: once the ДКП fallback fires for a thin (not zero) sample, the
|
||
explanation must not falsely claim 'рядом нет объявлений' — some WERE
|
||
found, just not enough to trust."""
|
||
dkp_raw = {
|
||
"count": 20,
|
||
"low_ppm2": 40_000,
|
||
"median_ppm2": 60_000,
|
||
"high_ppm2": 80_000,
|
||
"period_months": 12,
|
||
}
|
||
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]), dkp_raw=dkp_raw)
|
||
assert pr.explanation is not None
|
||
assert "рядом нет актуальных объявлений" not in pr.explanation.lower()
|
||
assert "сделкам росреестра" in pr.explanation.lower()
|
||
|
||
|
||
def test_thin_sample_listings_clean_preserved_for_anchor_ghost_guard() -> None:
|
||
"""Regression guard: the gate must suppress the AGGREGATE (median/n_analogs)
|
||
without clearing `listings_clean` itself — the same-building anchor's own
|
||
ghost-anchor guard (#1871) reads `listings_clean` truthiness to tell
|
||
"genuinely zero nearby listings" from "some nearby, just too few to trust
|
||
as headline", and conflating the two was caught regressing
|
||
test_estimator_split_corridor_1871.py during this change."""
|
||
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]))
|
||
assert pr.n_analogs == 0
|
||
assert len(pr.listings_clean) == 3
|
||
assert pr.listings_headline_thin_n == 3
|
||
|
||
|
||
def test_sufficient_sample_listings_headline_thin_n_is_zero() -> None:
|
||
"""Sanity/control: once n reaches the threshold, the thin-marker stays 0 —
|
||
downstream (estimate_quality) must not treat a healthy sample as thin."""
|
||
pr = _call(listings=_lots([200_000.0, 205_000.0, 210_000.0, 215_000.0, 220_000.0]))
|
||
assert pr.listings_headline_thin_n == 0
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_listing(*, price_per_m2: float, address: str, area_m2: float = 45.0) -> dict[str, Any]:
|
||
return {
|
||
"source": "avito",
|
||
"source_url": f"https://avito.ru/offer/{address}",
|
||
"address": address,
|
||
"lat": 59.604,
|
||
"lon": 60.577,
|
||
"rooms": 2,
|
||
"area_m2": area_m2,
|
||
"floor": 5,
|
||
"total_floors": 9,
|
||
"price_rub": price_per_m2 * area_m2,
|
||
"price_per_m2": price_per_m2,
|
||
"listing_date": datetime(2026, 5, 1),
|
||
"days_on_market": 10,
|
||
"photo_urls": [],
|
||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||
"distance_m": 150.0,
|
||
"relevance_score": 0.1,
|
||
}
|
||
|
||
|
||
def _serov_payload() -> Any:
|
||
from app.schemas.trade_in import TradeInEstimateInput
|
||
|
||
return TradeInEstimateInput(
|
||
address="Серов, ул. Ленина, 5",
|
||
area_m2=45.0,
|
||
rooms=2,
|
||
floor=5,
|
||
total_floors=9,
|
||
city_hint="Серов",
|
||
)
|
||
|
||
|
||
def _run_estimate(
|
||
*,
|
||
analogs: list[dict[str, Any]],
|
||
dkp_raw: dict[str, Any] | None,
|
||
) -> Any:
|
||
from app.services.estimator import estimate_quality
|
||
|
||
db = MagicMock()
|
||
payload = _serov_payload()
|
||
|
||
async def _run() -> Any:
|
||
with (
|
||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_geo())),
|
||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||
patch(
|
||
"app.services.estimator._fetch_analogs",
|
||
return_value=(list(analogs), False, "W"),
|
||
),
|
||
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
|
||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||
patch(
|
||
"app.services.estimator._get_or_fetch_imv_cached",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"app.services.estimator.estimate_via_cian_valuation",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||
):
|
||
return await estimate_quality(payload, db)
|
||
|
||
return anyio.run(_run)
|
||
|
||
|
||
def test_e2e_thin_no_deals_never_leaks_literal_zero_as_confident_price() -> None:
|
||
"""Каменск-Уральский-style repro: thin listings, no usable ДКП corridor —
|
||
median_price_rub must be 0 AND insufficient_data must be True TOGETHER
|
||
(the AggregatedEstimate.insufficient_data computed_field invariant that
|
||
stops a literal 0 ₽ reaching the user as a confident number)."""
|
||
analogs = [
|
||
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
|
||
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
|
||
]
|
||
est = _run_estimate(analogs=analogs, dkp_raw=None)
|
||
assert est.median_price_rub == 0
|
||
assert est.insufficient_data is True
|
||
assert est.n_analogs == 0
|
||
assert est.confidence == "low"
|
||
|
||
|
||
def test_e2e_thin_sample_display_cards_never_outnumber_n_analogs() -> None:
|
||
"""The 2 thin listings must NOT be surfaced as `analogs` display cards while
|
||
n_analogs reports 0 — that would be the same dishonesty (confident-looking
|
||
UI) this whole gate exists to remove."""
|
||
analogs = [
|
||
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
|
||
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
|
||
]
|
||
est = _run_estimate(analogs=analogs, dkp_raw=None)
|
||
assert est.n_analogs == 0
|
||
assert est.analogs == []
|
||
|
||
|
||
def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None:
|
||
"""Live Серов repro (n=3 scraped listings, wide ДКП corridor available):
|
||
headline must come from the deal corridor, not the noisy 3-listing median,
|
||
and the estimate must be honestly non-'insufficient' (a real number, low
|
||
confidence, deals-sourced)."""
|
||
analogs = [
|
||
_make_listing(price_per_m2=42_391.0, address="ул. Льва Толстого, 8А"),
|
||
_make_listing(price_per_m2=26_818.0, address="ул. Кирова, 4"),
|
||
_make_listing(price_per_m2=75_058.0, address="ул. Льва Толстого, 34"),
|
||
]
|
||
dkp_raw = {
|
||
"count": 54,
|
||
"low_ppm2": 44_000,
|
||
"median_ppm2": 65_957,
|
||
"high_ppm2": 89_000,
|
||
"period_months": 12,
|
||
}
|
||
est = _run_estimate(analogs=analogs, dkp_raw=dkp_raw)
|
||
assert est.median_price_per_m2 == 65_957
|
||
assert est.insufficient_data is False
|
||
assert est.n_analogs == 0
|
||
assert est.confidence == "low"
|
||
assert est.confidence_explanation is not None
|
||
assert "сделкам росреестра" in est.confidence_explanation.lower()
|
||
|
||
|
||
def test_e2e_sufficient_five_analogs_unaffected_control() -> None:
|
||
"""Control (mirrors the Екатеринбург prod check in the PR): a sample that
|
||
clears the threshold is priced exactly as before — headline is the real
|
||
listings median, all 5 analogs counted."""
|
||
analogs = [
|
||
_make_listing(price_per_m2=195_000.0, address="ул. Ленина, 5"),
|
||
_make_listing(price_per_m2=205_000.0, address="ул. Ленина, 7"),
|
||
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 9"),
|
||
_make_listing(price_per_m2=215_000.0, address="ул. Ленина, 11"),
|
||
_make_listing(price_per_m2=225_000.0, address="ул. Ленина, 13"),
|
||
]
|
||
est = _run_estimate(analogs=analogs, dkp_raw=None)
|
||
assert est.median_price_per_m2 == 210_000
|
||
assert est.n_analogs == 5
|
||
assert est.insufficient_data is False
|