"""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