gendesign/tradein-mvp/backend/tests/test_estimator_source_quota.py
Light1YT 35bd0238ef
All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 42s
Deploy Trade-In / deploy (push) Successful in 34s
feat(tradein): estimator additive expected_sold price (#648 S3) (#661)
2026-05-29 13:37:33 +00:00

189 lines
7.6 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.

"""Tests for _fetch_analogs per-address cap and per-source quota (source starvation fix).
Regression: Монтёрская 8/2 — 91 Avito listings с distance=0 выдавливали
Cian/Yandex/N1 из топ-50, т.к. pure-distance sort + LIMIT 50.
Fix: MAX_ANALOGS_PER_ADDRESS cap в SQL + MIN_ANALOGS_PER_SOURCE quota в Python.
"""
import os
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
from datetime import UTC, datetime
from typing import Any
from unittest.mock import MagicMock
# ── Helpers ───────────────────────────────────────────────────────────────────
def _make_listing(
*,
source: str,
address: str,
distance_m: float,
relevance_score: float | None = None,
price_rub: float = 5_000_000.0,
area_m2: float = 38.0,
rooms: int = 1,
) -> dict[str, Any]:
"""Construct a minimal listing dict mimicking DB mapping output."""
if relevance_score is None:
relevance_score = distance_m / 1000.0
return {
"source": source,
"source_url": f"https://{source}.ru/offer/1",
"address": address,
"lat": 56.838,
"lon": 60.595,
"rooms": rooms,
"area_m2": area_m2,
"floor": 3,
"total_floors": 16,
"price_rub": price_rub,
"price_per_m2": price_rub / area_m2,
"listing_date": datetime(2026, 5, 1),
"days_on_market": 10,
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": distance_m,
"relevance_score": relevance_score,
}
def _make_db_mock(rows: list[dict[str, Any]]) -> MagicMock:
"""Build a Session mock where db.execute().mappings().all() returns rows."""
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = rows
return db
# ── Test 1: per-address cap ───────────────────────────────────────────────────
def test_address_cap_limits_per_address_listings() -> None:
"""_fetch_analogs caps at MAX_ANALOGS_PER_ADDRESS listings from a single address.
SQL already applies rn_addr <= MAX_ANALOGS_PER_ADDRESS via window function.
This test verifies the Python post-processing does not accidentally bypass
the cap by confirming that when SQL returns exactly MAX_ANALOGS_PER_ADDRESS
rows per address, the result contains no more than that.
"""
from app.services.estimator import MAX_ANALOGS_PER_ADDRESS, _fetch_analogs
# SQL has already applied rn_addr <= MAX_ANALOGS_PER_ADDRESS.
# Simulate: SQL returns exactly MAX_ANALOGS_PER_ADDRESS avito rows (cap enforced).
addr = "ул. Монтёрская, 8/2"
sql_rows = [
_make_listing(source="avito", address=addr, distance_m=0.0, relevance_score=float(i))
for i in range(MAX_ANALOGS_PER_ADDRESS)
]
db = _make_db_mock(sql_rows)
result, fallback_used, _tier = _fetch_analogs(
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
)
avito_from_addr = [r for r in result if r["source"] == "avito" and r["address"] == addr]
assert len(avito_from_addr) <= MAX_ANALOGS_PER_ADDRESS, (
f"Expected at most {MAX_ANALOGS_PER_ADDRESS} avito from same address, "
f"got {len(avito_from_addr)}"
)
assert fallback_used is False
# ── Test 2: source quota (regression for Cian starvation) ────────────────────
def test_source_quota_prevents_cian_starvation() -> None:
"""MIN_ANALOGS_PER_SOURCE guarantees Cian is represented despite Avito dominance.
Regression: Монтёрская 8/2 — 60 Avito @ distance=0 + 8 Cian @ distance=200m.
Before fix: LIMIT 50 → 50 Avito, 0 Cian.
After fix: result contains >= min(8, MIN_ANALOGS_PER_SOURCE) Cian.
"""
from app.services.estimator import MIN_ANALOGS_PER_SOURCE, _fetch_analogs
# SQL already applied address cap. Simulate SQL result after cap:
# 5 avito (cap applied to large block), 8 cian (different address, 200m away).
avito_rows = [
_make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0,
relevance_score=float(i) * 0.01)
for i in range(5)
]
cian_rows = [
_make_listing(source="cian", address="ул. Монтёрская, 1", distance_m=200.0,
relevance_score=0.2 + float(i) * 0.01)
for i in range(8)
]
# SQL returns avito first (better relevance), then cian
sql_rows = avito_rows + cian_rows
db = _make_db_mock(sql_rows)
result, _, _ = _fetch_analogs(
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
)
cian_count = sum(1 for r in result if r["source"] == "cian")
expected_min = min(8, MIN_ANALOGS_PER_SOURCE)
assert cian_count >= expected_min, (
f"Expected >= {expected_min} Cian in result, got {cian_count}. "
"Source starvation bug not fixed."
)
# ── Test 3: no source starvation when quota > supply ─────────────────────────
def test_source_quota_includes_all_when_supply_below_min() -> None:
"""When a source has fewer listings than MIN_ANALOGS_PER_SOURCE, all are included.
Seed: 5 avito (after cap) + 3 cian @ 300m. All 3 cian must appear in result.
"""
from app.services.estimator import _fetch_analogs
avito_rows = [
_make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0,
relevance_score=float(i) * 0.01)
for i in range(5)
]
cian_rows = [
_make_listing(source="cian", address="ул. Монтёрская, 3", distance_m=300.0,
relevance_score=0.3 + float(i) * 0.01)
for i in range(3)
]
sql_rows = avito_rows + cian_rows
db = _make_db_mock(sql_rows)
result, _, _ = _fetch_analogs(
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
)
cian_count = sum(1 for r in result if r["source"] == "cian")
assert cian_count == 3, (
f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}"
)
assert len(result) == 8 # 5 avito + 3 cian
# ── Test 4: fallback signal preserved ────────────────────────────────────────
def test_fallback_signal_reflects_radius() -> None:
"""_fetch_analogs returns correct fallback_used boolean based on radius_m.
fallback_used=False when radius_m == DEFAULT_RADIUS_M (1000).
fallback_used=True when radius_m == FALLBACK_RADIUS_M (2000).
"""
from app.services.estimator import DEFAULT_RADIUS_M, FALLBACK_RADIUS_M, _fetch_analogs
rows = [
_make_listing(source="avito", address="ул. Ленина, 1", distance_m=100.0,
relevance_score=0.1),
]
db_default = _make_db_mock(rows)
_, fallback_default, _ = _fetch_analogs(
db_default, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=DEFAULT_RADIUS_M
)
assert fallback_default is False, "radius == DEFAULT should produce fallback_used=False"
db_fallback = _make_db_mock(rows)
_, fallback_wide, _ = _fetch_analogs(
db_fallback, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=FALLBACK_RADIUS_M
)
assert fallback_wide is True, "radius == FALLBACK should produce fallback_used=True"