gendesign/tradein-mvp/backend/tests/test_estimator_pure_units.py
bot-backend 159a02a800
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
test(tradein): quality-gate tests for 5 P0 audit fixes (Refs #781)
Anti-regression guards for PRs #877/#761/#784/#779/#743 (issues #755/#753/#773/#754/#740).
All 5 defects had no dedicated lock test -- any refactor could silently re-introduce them.

New file test_781_quality_gate.py (13 tests):
  #755: anchor n=2 < min_comps=4 does not fire; pure-unit confidence cap (n<5 != high)
  #753: compute_dedup_hash stable across reprice + ?context= query strip; source_id priority
  #773: expected_sold_price_rub > 0 on anchor-only path (radius=[]) when ratio present
  #754: page=1 HTTP-200 block raises AvitoContentBlockedError; page>1 graceful return
  #740: AggregatedEstimate.insufficient_data=True when median=0; survives model_dump()

Updated test_estimator_pure_units.py:
  test_filter_outliers_passthrough_below_5_lots -- added docstring clarifying this tests
  the radius-path _filter_outliers (n<5 bypass Tukey is unchanged by #755); #755 added
  MAD-clip only to _compute_same_building_anchor, not to the radius-path filter.

325 tests pass under -k anchor|dedup|expected_sold|avito|insufficient|filter_outliers.
ruff clean.
2026-06-06 22:16:15 +03:00

303 lines
10 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 estimator's pure numeric helpers (issue #580).
Covers the three side-effect-free functions:
- estimator._percentile — linear-interpolation percentile
- estimator._filter_outliers — Tukey 1.5×IQR outlier filter (None-safe)
- estimator._compute_confidence — confidence level + explanation string
No DB / network / mocks: these helpers operate on plain lists/dicts.
NOTE: importing app.services.estimator pulls app.core.config.Settings, which
requires DATABASE_URL. Set it BEFORE importing app modules (same pattern as
tests/test_scheduler.py).
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from app.services import estimator
# --------------------------------------------------------------------------- #
# _percentile
# --------------------------------------------------------------------------- #
# Assumes the input is ALREADY sorted ascending (documented in the source).
# These tests pass pre-sorted lists accordingly.
def test_percentile_empty_returns_zero() -> None:
assert estimator._percentile([], 0.5) == 0.0
def test_percentile_single_element_returns_that_element_as_float() -> None:
result = estimator._percentile([42], 0.5)
assert result == 42.0
assert isinstance(result, float)
def test_percentile_p0_returns_first_p1_returns_last() -> None:
values = [10.0, 20.0, 30.0, 40.0]
assert estimator._percentile(values, 0.0) == 10.0
assert estimator._percentile(values, 1.0) == 40.0
def test_percentile_median_odd_length() -> None:
assert estimator._percentile([10, 20, 30], 0.5) == 20.0
def test_percentile_median_even_length_interpolates() -> None:
# rank = 0.5 * (2 - 1) = 0.5 → 10 + (20 - 10) * 0.5 = 15.0
assert estimator._percentile([10, 20], 0.5) == 15.0
def test_percentile_known_interpolation_case() -> None:
# [100, 200, 300, 400] at p=0.25:
# rank = 0.25 * 3 = 0.75 → lo=0, hi=1, frac=0.75
# 100 + (200 - 100) * 0.75 = 175.0
assert estimator._percentile([100, 200, 300, 400], 0.25) == 175.0
def test_percentile_does_not_mutate_input() -> None:
values = [10, 20, 30, 40]
snapshot = list(values)
estimator._percentile(values, 0.5)
assert values == snapshot
# --------------------------------------------------------------------------- #
# _filter_outliers
# --------------------------------------------------------------------------- #
def _lot(ppm2: float | None) -> dict:
"""Build a minimal lot dict with the price_per_m2 key present."""
return {"price_per_m2": ppm2}
def test_filter_outliers_passthrough_below_5_lots() -> None:
"""Radius-path outlier filter: n<5 lots -> returned unchanged (bypass Tukey).
This tests _filter_outliers (radius-path listings), NOT the anchor-path
MAD-clip introduced by #755. The n<5 bypass in _filter_outliers is
intentional and was NOT changed by #755 -- #755 added MAD-clip only to
_compute_same_building_anchor (anchor comps), leaving the radius-path
filter unchanged. Post-#755 contract: 4 radius listings still bypass
Tukey (IQR requires >= 5 lots with >= 4 priced).
"""
lots = [_lot(100), _lot(200), _lot(300), _lot(400)]
assert estimator._filter_outliers(lots) is lots # returned unchanged
def test_filter_outliers_passthrough_fewer_than_4_priced() -> None:
# 5 lots but only 3 carry a usable price → passthrough (cannot compute IQR)
lots = [_lot(100), _lot(200), _lot(300), _lot(None), _lot(0)]
assert estimator._filter_outliers(lots) is lots
def test_filter_outliers_removes_high_outlier() -> None:
tight = [_lot(100), _lot(102), _lot(101), _lot(99), _lot(100)]
outlier = _lot(10_000)
lots = [*tight, outlier]
result = estimator._filter_outliers(lots)
assert outlier not in result
for lot in tight:
assert lot in result
def test_filter_outliers_removes_low_outlier() -> None:
tight = [_lot(1000), _lot(1010), _lot(990), _lot(1005), _lot(995)]
outlier = _lot(1)
lots = [*tight, outlier]
result = estimator._filter_outliers(lots)
assert outlier not in result
for lot in tight:
assert lot in result
def test_filter_outliers_none_priced_lot_does_not_raise_and_survives() -> None:
# Regression for issue #580: a lot where price_per_m2 key is PRESENT but None
# used to crash _filter_outliers with `low <= None <= high` → TypeError.
tight = [_lot(100), _lot(102), _lot(101), _lot(99), _lot(100)]
none_lot = _lot(None)
high_outlier = _lot(10_000)
lots = [*tight, none_lot, high_outlier]
# Must not raise.
result = estimator._filter_outliers(lots)
# The None-priced lot has no price to judge → retained.
assert none_lot in result
# Genuine priced outlier still dropped.
assert high_outlier not in result
# Tight cluster retained.
for lot in tight:
assert lot in result
def test_filter_outliers_all_identical_prices_removes_nothing() -> None:
# IQR == 0 → bounds collapse to the single value, every lot equals it.
lots = [_lot(500) for _ in range(6)]
result = estimator._filter_outliers(lots)
assert len(result) == len(lots)
# --------------------------------------------------------------------------- #
# _compute_confidence
# --------------------------------------------------------------------------- #
def _addr_lots(addresses: list[str]) -> list[dict]:
"""Build listings dicts carrying just an `address` key."""
return [{"address": addr} for addr in addresses]
def test_confidence_zero_median_returns_low_no_analogs_message() -> None:
level, explanation = estimator._compute_confidence(
n_analogs=0,
median_ppm2=0,
q1=0,
q3=0,
fallback_radius_used=False,
)
assert level == "low"
assert "не найдено аналогов" in explanation.lower()
def test_confidence_high_with_7_unique_addresses_and_tight_iqr() -> None:
# 7 unique addresses, IQR/median = (105-95)/100 = 0.10 < 0.15 → high.
# avg_lots_per_addr = 7 / 7 = 1.0 (no downgrade).
listings = _addr_lots([f"ул. Тестовая, {i}" for i in range(7)])
level, _ = estimator._compute_confidence(
n_analogs=7,
median_ppm2=100,
q1=95,
q3=105,
fallback_radius_used=False,
listings=listings,
)
assert level == "high"
def test_confidence_medium_via_4_unique_addresses() -> None:
# 4 unique addresses → medium branch (independent of IQR).
# Wide IQR ensures it is NOT "high". avg = 4/4 = 1.0 (no downgrade).
listings = _addr_lots(["a", "b", "c", "d"])
level, _ = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=70,
q3=130, # IQR/median = 0.60 → not high
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
def test_confidence_medium_via_2_unique_addresses_and_tight_iqr() -> None:
# 2 unique addresses AND IQR/median = 0.20 < 0.25 → medium.
# avg = 2/2 = 1.0 (no downgrade).
listings = _addr_lots(["a", "b"])
level, _ = estimator._compute_confidence(
n_analogs=2,
median_ppm2=100,
q1=90,
q3=110,
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
def test_confidence_low_single_address_wide_iqr() -> None:
listings = _addr_lots(["a"])
level, _ = estimator._compute_confidence(
n_analogs=1,
median_ppm2=100,
q1=50,
q3=150, # IQR/median = 1.0
fallback_radius_used=False,
listings=listings,
)
assert level == "low"
def test_confidence_downgrade_on_concentration_bias() -> None:
# 10 analogs across only 3 unique addresses:
# unique_addr_count = 3, IQR/median = 0.20 < 0.25 → base = "medium"
# avg_lots_per_addr = 10 / 3 = 3.33 > 2.5 → downgrade medium → low
listings = _addr_lots(["a", "b", "c"])
level, explanation = estimator._compute_confidence(
n_analogs=10,
median_ppm2=100,
q1=90,
q3=110,
fallback_radius_used=False,
listings=listings,
)
assert level == "low"
# Explanation should flag the concentration / bias.
assert "bias" in explanation.lower() or "на адрес" in explanation.lower()
def test_confidence_downgrade_high_to_medium() -> None:
# High profile (7 unique addrs, tight IQR) but heavily concentrated:
# n_analogs = 30, unique = 7 → avg = 4.28 > 2.5 → downgrade high → medium.
listings = _addr_lots([f"addr-{i}" for i in range(7)])
level, explanation = estimator._compute_confidence(
n_analogs=30,
median_ppm2=100,
q1=95,
q3=105, # IQR/median = 0.10 < 0.15
fallback_radius_used=False,
listings=listings,
)
assert level == "medium"
assert "bias" in explanation.lower() or "на адрес" in explanation.lower()
def test_confidence_fallback_radius_mentions_radius() -> None:
listings = _addr_lots(["a", "b", "c", "d"])
_, explanation = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=90,
q3=110,
fallback_radius_used=True,
listings=listings,
)
assert "радиус" in explanation.lower()
def test_confidence_area_widened_mentions_area() -> None:
listings = _addr_lots(["a", "b", "c", "d"])
_, explanation = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=90,
q3=110,
fallback_radius_used=False,
area_widened=True,
listings=listings,
)
assert "площад" in explanation.lower()
def test_confidence_listings_none_uses_n_analogs_as_unique_count() -> None:
# listings=None path: unique_addr_count = n_analogs, avg_lots_per_addr = 1.0
# (no crash, no downgrade). 4 "unique" → medium.
level, explanation = estimator._compute_confidence(
n_analogs=4,
median_ppm2=100,
q1=90,
q3=110,
fallback_radius_used=False,
listings=None,
)
assert level == "medium"
assert "4" in explanation # n_analogs surfaced in the message
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))