"""#2898: окно срока экспозиции (p25–p75) вместо одного числа и его персист. Раньше est_days_on_market был медианой возраста АКТИВНЫХ объявлений-аналогов и не сохранялся: отчёт по ссылке отдавал null. Теперь квартили exposure_days снятых объявлений считаются на POST, пишутся в trade_in_estimates и поднимаются на GET. Ожидаемые квартили посчитаны руками по формуле percentile_cont (позиция (n-1)·q, линейная интерполяция, ::int округляет .5 вверх), а не той же функцией. """ from __future__ import annotations import contextlib import os from typing import Any os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from unittest.mock import AsyncMock, MagicMock, patch import anyio from app.services.estimator import ( EXPOSURE_WINDOW_MIN_N, _exposure_window, _fetch_exposure_days, ) # 2..31 (n=30): p25 на позиции 7.25 → 9.25 → 9; p50 на 14.5 → 16.5 → 17 (банковское # округление дало бы 16); p75 на 21.75 → 23.75 → 24. _DAYS_30 = list(range(2, 32)) def test_quartiles_match_percentile_cont() -> None: w = _exposure_window(_DAYS_30) assert w is not None assert (w.p25_days, w.p50_days, w.p75_days, w.n) == (9, 17, 24, 30) def test_order_of_input_does_not_matter() -> None: w = _exposure_window(list(reversed(_DAYS_30))) assert w is not None assert (w.p25_days, w.p50_days, w.p75_days) == (9, 17, 24) def test_skewed_sample_window() -> None: """Прод-подобная выборка: пачка ровно по 31 дню (срок размещения на Авито) и хвост.""" days = [31] * 20 + [62] * 5 + [120, 180, 240, 300, 400] w = _exposure_window(days) assert w is not None # позиции 7.25 / 14.5 / 21.75 — внутри пачки 31 и пачки 62 assert (w.p25_days, w.p50_days, w.p75_days) == (31, 31, 62) def test_below_threshold_no_window() -> None: assert EXPOSURE_WINDOW_MIN_N == 30 assert _exposure_window(_DAYS_30[:-1]) is None assert _exposure_window([]) is None def test_fetch_failure_is_graceful() -> None: db = MagicMock() db.execute.side_effect = RuntimeError("boom") assert _fetch_exposure_days(db, lat=56.8, lon=60.6, rooms=1, area=40.0, radius_m=1000) == [] def test_fetch_without_coords_does_not_query() -> None: db = MagicMock() assert _fetch_exposure_days(db, lat=None, lon=60.6, rooms=1, area=40.0, radius_m=1000) == [] assert not db.execute.called # ── POST: окно уходит и в ответ, и в INSERT ─────────────────────────────────── def _run_estimate(exposure_days: list[int]) -> tuple[Any, dict[str, Any]]: from app.schemas.trade_in import TradeInEstimateInput from app.services.estimator import estimate_quality from app.services.geocoder import GeocodeResult db = MagicMock() payload = TradeInEstimateInput(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1) geo = GeocodeResult( lat=56.838, lon=60.595, full_address="Екатеринбург, ул. Тестовая, 1", provider="nominatim" ) fetch = MagicMock(return_value=exposure_days) patches = ( 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", return_value=([], False, "W")), 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._get_asking_sold_ratio", return_value=(None, None)), patch("app.services.estimator._fetch_exposure_days", new=fetch), ) async def _run() -> Any: with contextlib.ExitStack() as stack: for cm in patches: stack.enter_context(cm) return await estimate_quality(payload, db) result = anyio.run(_run) # Выборка берётся по координатам и параметрам оцениваемой квартиры. kwargs = fetch.call_args.kwargs assert (kwargs["lat"], kwargs["lon"], kwargs["rooms"], kwargs["area"]) == ( 56.838, 60.595, 1, 40.0, ) for call in db.execute.call_args_list: sql = str(getattr(call.args[0], "text", call.args[0])) if "INSERT INTO trade_in_estimates" in sql: return result, call.args[1] raise AssertionError("INSERT INTO trade_in_estimates не вызван") def test_post_persists_window_and_returns_it() -> None: result, params = _run_estimate(_DAYS_30) assert ( params["est_days_p25"], params["est_days_p50"], params["est_days_p75"], params["est_days_n"], ) == (9, 17, 24, 30) assert result.exposure_window is not None assert (result.exposure_window.p25_days, result.exposure_window.p75_days) == (9, 24) # Старое поле для старых клиентов — медиана того же окна, а не возраст объявлений. assert result.est_days_on_market == 17 def test_post_small_sample_persists_nulls() -> None: result, params = _run_estimate(_DAYS_30[:-1]) assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [ None, None, None, None, ] assert result.exposure_window is None assert result.est_days_on_market is None