All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m30s
Deploy Trade-In / test (push) Successful in 3m14s
Deploy Trade-In / build-backend (push) Successful in 7m24s
Deploy Trade-In / deploy (push) Successful in 1m25s
549 lines
24 KiB
Python
549 lines
24 KiB
Python
"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02, priority
|
||
RESTORED 2026-08-10) + #oblast-F — never-block relaxation cascade (product
|
||
decision, 2026-08-10).
|
||
|
||
History:
|
||
1. #oblast-E (2026-08-02) SUPPRESSED a thin (1..HEADLINE_LISTINGS_MIN_N-1)
|
||
listings sample to a literal zero, forcing the anchor/#oblast-D-deals-
|
||
fallback/insufficient_data chain to take over — motivated by a live
|
||
Серов repro (n=3 → 42 391 ₽/м², −36% vs the town's ДКП corridor of
|
||
54 126 ₽/м²).
|
||
2. #oblast-F (2026-08-10, first pass) reversed that suppression WHOLESALE —
|
||
a thin sample always kept its own median, even when a much more reliable
|
||
deals corridor was available. That accidentally REOPENED the exact Серов
|
||
bug #oblast-E existed to close.
|
||
3. #oblast-E priority RESTORED (2026-08-10, same day, product correction):
|
||
"никогда не блокировать вывод" ≠ "предпочитать шумную медиану по 3
|
||
объявлениям надёжному коридору по 54 сделкам". Final 3-way rule, in
|
||
`_price_from_inputs`'s gate:
|
||
- n_analogs >= HEADLINE_LISTINGS_MIN_N → listings median (unaffected).
|
||
- 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND a usable ДКП corridor
|
||
exists (count >= DEALS_HEADLINE_FALLBACK_MIN_N, median_ppm2 > 0) →
|
||
listings aggregate suppressed to zero, headline ceded to the
|
||
#oblast-D deals-headline-fallback chain (original #oblast-E
|
||
behaviour, restored). `PricingResult.deals_headline_due_to_thin_
|
||
listings=True` — estimate_quality() adds relaxation label "оценка по
|
||
сделкам — мало объявлений рядом" and caps reliability at 'low'.
|
||
Listings display cards are NOT hidden (unlike original #oblast-E) —
|
||
`listings_clean` stays intact and estimate_quality() still surfaces
|
||
them as context even though they no longer drive n_analogs/median.
|
||
- 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND no usable ДКП corridor →
|
||
#oblast-F: keep the real thin median (never refuse outright).
|
||
Real refusal ("недостаточно данных") now happens ONLY at genuine n=0
|
||
(no listings AND no usable anchor/deals) — the never-block requirement
|
||
with an honest, priority-ordered source selection.
|
||
|
||
`estimate_quality()` tries to grow a thin sample FIRST via the #oblast-F
|
||
relaxation cascade (room-adjacency / freshness / novostroyki / radius, see
|
||
estimator.py module docstring) BEFORE `_price_from_inputs` (tested here in
|
||
Layer 1) ever runs the 3-way gate above — `listings` here is whatever that
|
||
cascade could find.
|
||
|
||
Two layers:
|
||
1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) —
|
||
boundary behaviour of the gate itself: the 3-way rule, low-reliability
|
||
wording, listings_clean/listings_headline_thin_n/deals_headline_due_to_
|
||
thin_listings bookkeeping.
|
||
2. `estimate_quality` integration tests — proves the money-path invariants
|
||
that matter to a caller: thin+usable-deals routes to the deals corridor
|
||
(Серов repro), thin+no-deals keeps its own median, display `analogs`
|
||
cards are shown either way, and the #oblast-F room-adjacency relaxation
|
||
(studio↔1-комн) actually grows a thin sample and is reported via
|
||
`AggregatedEstimate.relaxations` / `reliability`.
|
||
"""
|
||
|
||
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_kept_not_suppressed() -> None:
|
||
"""#oblast-F: n=4 (< 5) → the REAL 4-listing median is kept (product decision
|
||
2026-08-10 — never zero out a thin-but-real sample), just flagged low."""
|
||
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0]))
|
||
assert pr.median_ppm2 == 215_000.0
|
||
assert pr.n_analogs == 4
|
||
assert pr.median_price == round(215_000.0 * 45.0)
|
||
assert pr.confidence == "low"
|
||
|
||
|
||
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_kept_not_suppressed() -> None:
|
||
"""#oblast-F: n=1 — the sharpest thin case — still keeps its own (single-lot)
|
||
median rather than being zeroed; confidence stays 'low'."""
|
||
pr = _call(listings=_lots([200_000.0]))
|
||
assert pr.median_ppm2 == 200_000.0
|
||
assert pr.n_analogs == 1
|
||
assert pr.confidence == "low"
|
||
|
||
|
||
def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None:
|
||
"""#oblast-E priority RESTORED (2026-08-10 product correction): a thin
|
||
(n=3) listings sample must NOT outrank a usable ДКП deals corridor — this
|
||
is the exact live Серов repro #oblast-E exists for (3 noisy listings gave
|
||
42 391 ₽/м², the honest 54-deal corridor gives 65 957 ₽/м²). Headline
|
||
comes from the deal corridor median, NOT the 3-listing median."""
|
||
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"
|
||
assert pr.deals_headline_due_to_thin_listings is True
|
||
assert pr.listings_clean, "listings_clean must stay intact — display cards still show them"
|
||
# #4: explanation must not falsely claim "рядом нет объявлений" (some WERE
|
||
# found, just ceded priority to the more reliable deals corridor) and must
|
||
# NOT also carry the separate "Оценка построена по N аналогам" thin-kept
|
||
# wording (that phrasing is reserved for the no-usable-corridor branch).
|
||
assert pr.explanation is not None
|
||
assert "рядом нет актуальных объявлений" not in pr.explanation.lower()
|
||
assert "сделкам росреестра" in pr.explanation.lower()
|
||
assert "оценка построена по 3" not in pr.explanation.lower()
|
||
|
||
|
||
def test_thin_sample_with_thin_deals_also_uses_real_listings_median() -> None:
|
||
"""n=3 listings (thin) + a ДКП corridor that is ITSELF too thin
|
||
(< DEALS_HEADLINE_FALLBACK_MIN_N) → the corridor is NOT usable, so
|
||
#oblast-F's never-block rule applies: the real listings median is kept
|
||
rather than refusing (neither source alone would justify a hard zero)."""
|
||
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 == 42_391.0
|
||
assert pr.n_analogs == 3
|
||
assert pr.deals_headline_due_to_thin_listings is False
|
||
|
||
|
||
def test_thin_sample_explanation_is_honest_about_low_accuracy() -> None:
|
||
"""#4 (task spec): the explanation for a thin-but-real sample must read as
|
||
"small sample, lower accuracy" — NOT the old refusal-flavoured "минимум для
|
||
оценки по рынку" copy, and NOT the generic zero-analogs text."""
|
||
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()
|
||
assert "точность снижена" in pr.explanation.lower()
|
||
assert "минимум для оценки по рынку" not in pr.explanation.lower()
|
||
assert "не найдено аналогов" not in pr.explanation.lower()
|
||
|
||
|
||
def test_zero_listings_with_sufficient_deals_still_uses_deals_headline() -> None:
|
||
"""Control: the #oblast-D deals-headline-fallback path is UNCHANGED for
|
||
GENUINELY zero listings (n=0) — #oblast-F only affects the 1..N-1 thin
|
||
case, not the true-zero case, which still needs a fallback source."""
|
||
dkp_raw = {
|
||
"count": 54,
|
||
"low_ppm2": 44_000,
|
||
"median_ppm2": 65_957,
|
||
"high_ppm2": 89_000,
|
||
"period_months": 12,
|
||
}
|
||
pr = _call(listings=[], dkp_raw=dkp_raw)
|
||
assert pr.median_ppm2 == 65_957.0
|
||
assert pr.n_analogs == 0
|
||
assert pr.confidence == "low"
|
||
assert pr.explanation is not None
|
||
assert "рядом нет актуальных объявлений" in pr.explanation.lower()
|
||
assert "сделкам росреестра" in pr.explanation.lower()
|
||
|
||
|
||
def test_thin_sample_listings_clean_preserved_and_thin_n_still_tracked() -> None:
|
||
"""listings_clean stays intact (unchanged invariant — same-building anchor's
|
||
ghost-anchor guard #1871 depends on it) AND, post-#oblast-F, n_analogs is
|
||
the REAL count (not zeroed) while listings_headline_thin_n still marks the
|
||
sample as thin for the low-reliability note upstream."""
|
||
pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0]))
|
||
assert pr.n_analogs == 3
|
||
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
|
||
|
||
|
||
def test_repair_coefficient_now_applies_to_thin_sample() -> None:
|
||
"""#oblast-F: pre-#oblast-F, the repair-state coefficient was skipped for a
|
||
thin sample because the headline was already zeroed (applying it would be a
|
||
no-op). Now that the real median is kept, the coefficient must apply."""
|
||
pr_no_repair = _call(listings=_lots([200_000.0, 210_000.0])) # n=2, thin
|
||
pr = _price_from_inputs(
|
||
listings=_lots([200_000.0, 210_000.0]),
|
||
area_m2=45.0,
|
||
rooms=2,
|
||
repair_state="excellent",
|
||
floor=5,
|
||
total_floors=9,
|
||
target_year=None,
|
||
analog_tier="W",
|
||
fallback_used=False,
|
||
area_widened=False,
|
||
anchor_comps=[],
|
||
anchor_tier_fetched=None,
|
||
dkp_raw=None,
|
||
imv_anchor=None,
|
||
imv_eval=None,
|
||
yandex_val_present=False,
|
||
cian_val_present=False,
|
||
ratio_resolver=lambda _appm2: (None, None),
|
||
quarter_index_lookup=lambda q: None,
|
||
quarter_indexes_lookup=lambda qs: {},
|
||
target_house_cadnum=None,
|
||
dadata_coarse=False,
|
||
geo=_geo(),
|
||
dadata_qc_geo=None,
|
||
)
|
||
assert (
|
||
pr.median_price != pr_no_repair.median_price
|
||
), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 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]] | None = None,
|
||
dkp_raw: dict[str, Any] | None,
|
||
fetch_analogs_side_effect: Any = None,
|
||
payload: Any = None,
|
||
geo: GeocodeResult | None = None,
|
||
) -> Any:
|
||
from app.services.estimator import estimate_quality
|
||
|
||
db = MagicMock()
|
||
payload = payload or _serov_payload()
|
||
geo = geo or _geo()
|
||
|
||
fetch_analogs_kwargs: dict[str, Any] = (
|
||
{"side_effect": fetch_analogs_side_effect}
|
||
if fetch_analogs_side_effect is not None
|
||
else {"return_value": (list(analogs or []), False, "W")}
|
||
)
|
||
|
||
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", **fetch_analogs_kwargs),
|
||
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_sample_no_relaxation_help_keeps_real_median() -> None:
|
||
"""#oblast-F: 2 thin listings, no ДКП, and the mocked `_fetch_analogs` always
|
||
returns the SAME 2 listings regardless of relaxation params (none of them
|
||
help) — median_price_rub must be the REAL non-zero 2-listing median,
|
||
insufficient_data False, n_analogs=2, confidence='low', reliability
|
||
'very_low' (n<3), relaxations empty (nothing actually helped)."""
|
||
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 == round(205_000.0 * 45.0)
|
||
assert est.insufficient_data is False
|
||
assert est.n_analogs == 2
|
||
assert est.confidence == "low"
|
||
assert est.relaxations == []
|
||
assert est.reliability == "very_low"
|
||
|
||
|
||
def test_e2e_thin_sample_display_cards_match_n_analogs() -> None:
|
||
"""#oblast-F: display `analogs` cards are NO LONGER suppressed for a thin
|
||
sample — they must match n_analogs exactly (both = 2), never hidden."""
|
||
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 == 2
|
||
assert len(est.analogs) == 2
|
||
|
||
|
||
def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None:
|
||
"""Live Серов repro (n=3 scraped listings, wide ДКП corridor available) —
|
||
#oblast-E priority RESTORED: headline must come from the deal corridor,
|
||
not the noisy 3-listing median. Also proves the #4 task-spec requirements
|
||
layered on top of the restored priority: the estimate is honestly non-
|
||
'insufficient' (a real number, low confidence), reliability is capped at
|
||
'low' (not 'very_low' — a 54-deal corridor is real signal), the
|
||
relaxation label names the source switch, AND the 3 thin listings are
|
||
still shown as display cards (not discarded) even though they no longer
|
||
drive n_analogs/median."""
|
||
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()
|
||
assert est.reliability == "low", "a 54-deal corridor is real signal, not 'very_low'"
|
||
assert "оценка по сделкам — мало объявлений рядом" in est.relaxations
|
||
assert len(est.analogs) == 3, "thin listings must still surface as display cards"
|
||
|
||
|
||
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, no relaxations needed."""
|
||
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
|
||
assert est.relaxations == []
|
||
assert est.reliability == "low" # n=5 falls in the 3..7 bucket
|
||
|
||
|
||
def test_e2e_rooms_relaxation_includes_studios_when_thin() -> None:
|
||
"""#oblast-F step (a) — the exact scenario from the task spec: rooms=1 thin
|
||
sample (studio-adjacent building, live prod repro Академика Парина 46/5) →
|
||
cascade retries with rooms IN (0,1) and finds a trustworthy sample there.
|
||
Asserts: studios pulled in, `relaxations` names it, real non-zero median,
|
||
reliability downgraded to 'low' (thin base sample)."""
|
||
from app.schemas.trade_in import TradeInEstimateInput
|
||
|
||
exact_rooms1 = [
|
||
_make_listing(price_per_m2=150_000.0, address="ул. Парина, 1", area_m2=23.0),
|
||
_make_listing(price_per_m2=155_000.0, address="ул. Парина, 2", area_m2=23.0),
|
||
]
|
||
studio_pool = [
|
||
*exact_rooms1,
|
||
_make_listing(price_per_m2=140_000.0, address="ул. Парина, 3", area_m2=20.0),
|
||
_make_listing(price_per_m2=145_000.0, address="ул. Парина, 4", area_m2=21.0),
|
||
_make_listing(price_per_m2=148_000.0, address="ул. Парина, 5", area_m2=22.0),
|
||
]
|
||
|
||
def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]:
|
||
if kwargs.get("rooms_min") == 0 and kwargs.get("rooms_max") == 1:
|
||
return list(studio_pool), False, "W"
|
||
return list(exact_rooms1), False, "W"
|
||
|
||
geo = GeocodeResult(
|
||
lat=56.838,
|
||
lon=60.595,
|
||
full_address="Свердловская обл., Екатеринбург, ул. Парина, 46/5",
|
||
provider="nominatim",
|
||
)
|
||
payload = TradeInEstimateInput(
|
||
address="ЕКБ, ул. Парина, 46/5",
|
||
area_m2=23.1,
|
||
rooms=1,
|
||
)
|
||
|
||
est = _run_estimate(
|
||
dkp_raw=None,
|
||
fetch_analogs_side_effect=_fetch_analogs_stub,
|
||
payload=payload,
|
||
geo=geo,
|
||
)
|
||
|
||
assert "учтены студии" in est.relaxations
|
||
assert est.median_price_rub > 0
|
||
assert est.reliability == "low"
|
||
assert est.n_analogs == 5
|
||
|
||
|
||
def test_e2e_radius_relaxation_respects_explicit_user_radius() -> None:
|
||
"""#oblast-F step (d) contract: when the user explicitly picked radius_m
|
||
(#2044), the cascade must NOT auto-expand past it — mirrors the existing
|
||
radius-fallback contract above (no auto-expansion beyond user's choice)."""
|
||
from app.schemas.trade_in import TradeInEstimateInput
|
||
|
||
thin = [
|
||
_make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"),
|
||
_make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"),
|
||
]
|
||
payload = TradeInEstimateInput(
|
||
address="Серов, ул. Ленина, 5",
|
||
area_m2=45.0,
|
||
rooms=2,
|
||
floor=5,
|
||
total_floors=9,
|
||
city_hint="Серов",
|
||
radius_m=1500,
|
||
)
|
||
est = _run_estimate(analogs=thin, dkp_raw=None, payload=payload)
|
||
assert not any("радиус расширен" in r for r in est.relaxations)
|
||
assert est.search_radius_m == 1500
|