All checks were successful
Deploy Trade-In / test (push) Successful in 1m31s
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 / build-backend (push) Successful in 1m18s
Deploy Trade-In / deploy (push) Successful in 46s
220 lines
9 KiB
Python
220 lines
9 KiB
Python
"""Hermetic unit tests for the #2209 min-width range floor.
|
||
|
||
Two layers:
|
||
1. The pure ``_apply_range_floor`` helper (no DB, no imports beyond estimator).
|
||
2. Integration through ``_price_from_inputs`` — a degenerate n=1 analog sample
|
||
(q1==q3==median → zero-width asking range) must surface a non-zero ±12 %
|
||
range; a naturally-wide range must be left untouched; the floor must run
|
||
AFTER the IMV blend (a narrow post-blend range gets widened, never shrunk).
|
||
|
||
NOTE: importing app.services.estimator pulls app.core.config.Settings which
|
||
requires DATABASE_URL. Set it BEFORE importing app modules.
|
||
"""
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services import estimator
|
||
from app.services.estimator import RANGE_MIN_HALFWIDTH_PCT, _apply_range_floor
|
||
from app.services.geocoder import GeocodeResult
|
||
|
||
# ── helpers (mirror tests/test_estimator_price_spine.py) ──────────────────────
|
||
|
||
|
||
def _geo() -> GeocodeResult:
|
||
return GeocodeResult(
|
||
lat=56.838,
|
||
lon=60.597,
|
||
full_address="ул. Тестовая, 1",
|
||
provider="nominatim",
|
||
confidence="approximate",
|
||
)
|
||
|
||
|
||
def _lot(ppm2: float, address: str = "ул. Тестовая, 1", source: str = "avito") -> dict:
|
||
return {"price_per_m2": ppm2, "address": address, "source": source}
|
||
|
||
|
||
def _lots(ppm2: float, n: int = 7) -> list[dict]:
|
||
return [_lot(ppm2, address=f"ул. Тестовая, {i + 1}") for i in range(n)]
|
||
|
||
|
||
def _call(
|
||
*,
|
||
listings: list[dict],
|
||
area_m2: float = 50.0,
|
||
rooms: int | None = 2,
|
||
imv_anchor: dict | None = None,
|
||
ratio: float | None = None,
|
||
) -> estimator.PricingResult:
|
||
_ratio = ratio
|
||
_basis = "per_rooms" if ratio is not None else None
|
||
|
||
def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
|
||
return _ratio, _basis if _ratio is not None else None
|
||
|
||
return estimator._price_from_inputs(
|
||
listings=listings,
|
||
area_m2=area_m2,
|
||
rooms=rooms,
|
||
repair_state=None,
|
||
floor=5,
|
||
total_floors=10,
|
||
target_year=None,
|
||
analog_tier="W",
|
||
fallback_used=False,
|
||
area_widened=False,
|
||
anchor_comps=[],
|
||
anchor_tier_fetched=None,
|
||
dkp_raw=None,
|
||
imv_anchor=imv_anchor,
|
||
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,
|
||
)
|
||
|
||
|
||
# ── 1. Pure helper ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_floor_expands_zero_width_symmetrically() -> None:
|
||
"""point=5M, low==high==5M (n=1 collapse) → symmetric ±12 % around point."""
|
||
half = round(RANGE_MIN_HALFWIDTH_PCT * 5_000_000) # 600_000
|
||
low, high = _apply_range_floor(5_000_000, 5_000_000, 5_000_000)
|
||
assert low == 5_000_000 - half
|
||
assert high == 5_000_000 + half
|
||
# Point is the exact midpoint — floor never moves the point.
|
||
assert (low + high) // 2 == 5_000_000
|
||
|
||
|
||
def test_floor_leaves_wide_range_untouched() -> None:
|
||
"""A range already wider than 2×12 % of point is returned verbatim."""
|
||
# width = 3M, point 5M → rel 0.60 >> 0.24 floor → no-op.
|
||
assert _apply_range_floor(3_500_000, 6_500_000, 5_000_000) == (3_500_000, 6_500_000)
|
||
|
||
|
||
def test_floor_clamps_low_to_zero() -> None:
|
||
"""When point < floor_half the lower edge clamps at 0 (never negative)."""
|
||
# point=1M → floor_half=120_000; 1M-120k=880k stays positive, so pick a tiny point.
|
||
half = round(RANGE_MIN_HALFWIDTH_PCT * 100_000) # 12_000
|
||
low, _high = _apply_range_floor(50_000, 50_000, 100_000)
|
||
assert low == 100_000 - half # 88_000 — still positive here
|
||
# Now a point whose floor_half exceeds it → low clamps to 0.
|
||
low2, high2 = _apply_range_floor(5, 5, 10)
|
||
floor_half2 = round(RANGE_MIN_HALFWIDTH_PCT * 10) # ~1
|
||
assert low2 == max(0, 10 - floor_half2)
|
||
assert low2 >= 0
|
||
assert high2 == 10 + floor_half2
|
||
|
||
|
||
def test_floor_noop_when_point_nonpositive() -> None:
|
||
"""point<=0 → range returned unchanged (no division/expansion)."""
|
||
assert _apply_range_floor(0, 0, 0) == (0, 0)
|
||
assert _apply_range_floor(100, 200, 0) == (100, 200)
|
||
|
||
|
||
def test_floor_only_widens_never_shrinks() -> None:
|
||
"""A range exactly at the floor boundary is preserved (not shrunk)."""
|
||
point = 1_000_000
|
||
half = round(RANGE_MIN_HALFWIDTH_PCT * point) # 120_000
|
||
# width == 2*half exactly → NOT < 2*half → untouched.
|
||
assert _apply_range_floor(point - half, point + half, point) == (
|
||
point - half,
|
||
point + half,
|
||
)
|
||
|
||
|
||
# ── 2. Integration through _price_from_inputs ────────────────────────────────
|
||
|
||
|
||
def test_single_analog_asking_range_gets_nonzero_width() -> None:
|
||
"""n=1 analog: q1==q3==median → zero-width asking range → floored to ±12 %."""
|
||
pr = _call(listings=[_lot(100_000)])
|
||
assert pr.n_analogs == 1
|
||
point = pr.median_price # 100_000 × 50 = 5_000_000
|
||
assert point == 5_000_000
|
||
half = round(RANGE_MIN_HALFWIDTH_PCT * point)
|
||
assert pr.range_low == point - half
|
||
assert pr.range_high == point + half
|
||
# Non-degenerate: the range is no longer a single point.
|
||
assert pr.range_high > pr.range_low
|
||
# Point (median) itself is untouched — floor never moves it.
|
||
assert pr.median_ppm2 == 100_000.0
|
||
|
||
|
||
def test_wide_analog_range_not_floored() -> None:
|
||
"""A radius sample with real spread keeps its Q1..Q3 asking range unchanged."""
|
||
# Spread wide enough that (q3-q1)/median > 0.24.
|
||
lots = [_lot(p) for p in (60_000, 80_000, 100_000, 120_000, 140_000)]
|
||
pr = _call(listings=lots)
|
||
point = pr.median_price
|
||
half = round(RANGE_MIN_HALFWIDTH_PCT * point)
|
||
# Real IQR width exceeds the floor → floor is a no-op here.
|
||
assert (pr.range_high - pr.range_low) >= 2 * half
|
||
# And the edges are the genuine Q1/Q3 totals, not point±half.
|
||
assert pr.range_low != point - half
|
||
|
||
|
||
def test_expected_sold_range_floored_around_expected_point() -> None:
|
||
"""Degenerate n=1 with a ratio: expected_sold range also gets ±12 % of its point."""
|
||
pr = _call(listings=[_lot(100_000)], ratio=0.90)
|
||
assert pr.expected_sold_price is not None
|
||
assert pr.expected_sold_range_low is not None
|
||
assert pr.expected_sold_range_high is not None
|
||
esp = pr.expected_sold_price
|
||
# Calibrated PI band (0.649..1.392 of point) is far wider than the 12 % floor,
|
||
# so the expected_sold range is NOT degenerate and the floor is a no-op on it —
|
||
# but it must still bracket the point and stay non-negative.
|
||
assert pr.expected_sold_range_low <= esp <= pr.expected_sold_range_high
|
||
assert pr.expected_sold_range_low >= 0
|
||
assert (pr.expected_sold_range_high - pr.expected_sold_range_low) >= round(
|
||
2 * RANGE_MIN_HALFWIDTH_PCT * esp
|
||
)
|
||
|
||
|
||
def test_ppm2_point_consistent_with_floored_range() -> None:
|
||
"""Serialized ppm² point stays the analog median; floor only widens rub range.
|
||
|
||
The response serializes a single ppm² point (median_ppm2 / expected_sold_per_m2),
|
||
not a ppm² range, so цена↔ppm² consistency means the point ppm² must equal
|
||
median_price/area even after the range floor widens the rub band.
|
||
"""
|
||
pr = _call(listings=[_lot(100_000)], area_m2=50.0)
|
||
assert pr.median_ppm2 == pr.median_price / 50.0
|
||
# Floor widened the rub range but did not touch the ppm² point.
|
||
assert pr.median_ppm2 == 100_000.0
|
||
|
||
|
||
def test_floor_applied_after_imv_blend() -> None:
|
||
"""Floor runs LAST: a narrow post-IMV-blend asking range is widened, not shrunk.
|
||
|
||
Uniform lots → zero-width Q1..Q3. IMV anchor just above threshold (no higher_price)
|
||
blends the median up and lifts range_high to the anchor total, leaving a range that
|
||
is still narrower than the 12 % floor. The floor must then widen it symmetrically
|
||
around the POST-BLEND point — proving the floor sees post-blend values.
|
||
"""
|
||
imv_anchor = {
|
||
"recommended_price": 5_800_000, # > 5M × 1.15 = 5.75M → blends
|
||
"lower_price": 5_500_000,
|
||
"higher_price": None, # so range_top_candidate == anchor_total (5.8M)
|
||
"market_count": 50,
|
||
}
|
||
pr = _call(listings=_lots(100_000, n=5), imv_anchor=imv_anchor)
|
||
# Blend fired: point moved to round(5M×0.5 + 5.8M×0.5) = 5.4M.
|
||
point = pr.median_price
|
||
assert point == 5_400_000
|
||
half = round(RANGE_MIN_HALFWIDTH_PCT * point) # 648_000
|
||
# Pre-floor the post-blend range was [5.0M, 5.8M] (width 0.8M < 2×648k) → floored.
|
||
assert pr.range_low == point - half # 4_752_000 < 5.0M pre-floor low
|
||
assert pr.range_high == point + half # 6_048_000 > 5.8M pre-floor high
|
||
# Floor only WIDENED: high moved above the blend's 5.8M, low below the 5.0M q1.
|
||
assert pr.range_high > 5_800_000
|
||
assert pr.range_low < 5_000_000
|