fix(tradein/coverage): resolve city by coordinates, not sweep-context city_hint
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m34s

Повторная проверка /coverage закрыла оба MAJOR из #2894, но выявила три
новых дефекта:

1. Город больше не резолвится из моды listings.city найденной когорты —
   эта колонка хранит город SWEEP-контекста скрейпера (миграция 196), не
   геокод адреса объявления. Замер на проде: 90/90 строк в радиусе 1000м
   вокруг Берёзовского имеют city='Екатеринбург', 74/74 вокруг Ревды —
   city='Первоуральск'. Города-спутники из COVERAGE_GREEN/YELLOW_CITIES были
   физически недостижимы. Город теперь резолвится детерминированно по
   lat/lon запроса — ближайший центроид из статичной константы (8 городов,
   рядом с ручкой, не в БД — comment объясняет почему) в пределах 25 км.
   city_hint остаётся в схеме (фронт его шлёт для соседних ручек), но чисто
   информационный — на порог/статус не влияет.

2. test_max_age_outlier_days_passed_to_sql проверял подстроку, которая
   встречается в SQL дважды (count и percentile_cont) — мутация «убрать
   FILTER у percentile_cont, оставив у count» проходила зелёной. Добавлен
   живой поведенческий тест (вставляет когорту + выброс days_on_market=4000,
   проверяет что медиана не сдвигается) — ловит эту мутацию (подтверждено:
   median 8→9 при мутации).

3. _live_session() вызывался в pytest.mark.skipif на этапе сбора тестов и
   создавал никогда не закрываемый Session, плюс дублировался в теле теста.
   Заменено на _live_db_available() (open+close голого connection) для
   skipif и pytest-фикстуру live_session с гарантированным close/dispose.

4. Nit: пустая когорта в поддерживаемом городе отдавала status=not_covered
   вместе с ненулевым threshold — противоречило докстрингу
   CoverageProbeResponse.threshold ("0, когда порог неприменим"). threshold
   теперь всегда 0 при not_covered, независимо от причины.
This commit is contained in:
bot-backend 2026-08-15 21:20:43 +03:00
parent 3e9af2fdef
commit 37e738c802
4 changed files with 408 additions and 177 deletions

View file

@ -9,6 +9,7 @@ import asyncio
import calendar import calendar
import json import json
import logging import logging
import math
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from uuid import UUID from uuid import UUID
@ -2592,28 +2593,72 @@ _COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES}, **{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
} }
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
# `listings.city` найденной когорты — оказалось, что `listings.city` это город
# СВИП-контекста скрейпера (миграция 196 — колонка заполняется тем городом,
# который скрейпер обходил, не геокодом самого объявления). Замер на проде:
# в радиусе 1000 м вокруг Берёзовского 90/90 строк имеют city='Екатеринбург';
# вокруг Ревды 74/74 — city='Первоуральск'. Следствие: продавец в Берёзовском
# видел на лэндинге «Екатеринбург», а сами COVERAGE_GREEN/YELLOW_CITIES для
# городов-спутников были НЕДОСТИЖИМЫ (в БД нет ни одной строки с их city).
# Фикс — детерминированный резолв по координатам ЗАПРОСА (никакого участия
# клиента, никакой моды когорты): ближайший центроид города из списка ниже,
# если он в пределах COVERAGE_CITY_MATCH_RADIUS_KM.
#
# Координаты — константа РЯДОМ С РУЧКОЙ, не таблица в БД: единственный
# существующий кандидат на "готовый реестр городов" — это
# frontend/src/lib/city-registry.ts (OBLAST_CITIES) и backend
# geocoder.py::SVERDLOVSK_OBLAST_CITIES — оба хранят ТОЛЬКО текстовые лейблы
# (city_hint для геокодера), без координат. Заводить миграцию + таблицу ради
# статичного справочника из 8 географических центров населённых пунктов —
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
# рядом с порогами, которые они резолвят.
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
def _resolve_coverage_city(city_hint: str | None, cohort_city: str | None) -> tuple[str, int, bool]: _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия. "Екатеринбург": (56.8389, 60.6057),
"Верхняя Пышма": (56.9789, 60.5636),
"Берёзовский": (56.9096, 60.8034),
"Среднеуральск": (56.9848, 60.4759),
"Нижний Тагил": (57.9099, 59.9819),
"Каменск-Уральский": (56.4110, 61.9243),
"Первоуральск": (56.9083, 59.9483),
"Ревда": (56.7986, 59.9298),
}
MINOR fix (независимый ревью #2894): city_hint — это НЕ проверенный вход,
клиент им управляет напрямую (lat/lon в Серове + city_hint='Екатеринбург' def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
раньше давал threshold=8 и status='ok' клиент выбирал себе порог). Источник """Расстояние по большому кругу (км), радиус Земли 6371 км."""
истины мода city найденной SQL-когорты (то, что реально лежит в БД рядом с r_earth_km = 6371.0
переданными lat/lon); city_hint используется ТОЛЬКО как фолбэк, когда когорта phi1, phi2 = math.radians(lat1), math.radians(lat2)
пуста (cohort_city is None) в этом случае n_listings тоже 0, и caller всё dphi = math.radians(lat2 - lat1)
равно форсирует status="not_covered" независимо от threshold/supported, так dlambda = math.radians(lon2 - lon1)
что подмена клиентом порога здесь не даёт эффекта. a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
Эхо произвольной клиентской строки в поле city убрано: candidate, не нашедший return 2 * r_earth_km * math.asin(math.sqrt(a))
совпадения в зелёном/жёлтом списке, отдаётся как "" (не supported), а не как
сырой ввод.
def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
и без участия клиента см. комментарий над `_CITY_CENTROIDS_DEG` про то,
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
больше НЕ допускаются в качестве источника истины.
""" """
candidate = (cohort_city or city_hint or "").strip() nearest_city: str | None = None
match = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(candidate)) if candidate else None nearest_km = math.inf
if match is not None: for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
display, threshold = match distance_km = _haversine_km(lat, lon, clat, clon)
return display, threshold, True if distance_km < nearest_km:
return "", 0, False nearest_km = distance_km
nearest_city = city
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
return "", 0, False
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
return display, threshold, True
@router.post("/coverage", response_model=CoverageProbeResponse) @router.post("/coverage", response_model=CoverageProbeResponse)
@ -2654,6 +2699,14 @@ def coverage_probe(
сведение двух разных когорт усложнило бы «один дешёвый SQL» без выигрыша сведение двух разных когорт усложнило бы «один дешёвый SQL» без выигрыша
в честности (у нас и так честное имя поля age активного объявления, не в честности (у нас и так честное имя поля age активного объявления, не
срок продажи). См. openQuestions PR #2894 при ревью. срок продажи). См. openQuestions PR #2894 при ревью.
Повторная проверка ручки (2026-08): город больше НЕ берётся из моды
`listings.city` найденной когорты и НЕ зависит от `payload.city_hint`
оба источника ненадёжны (см. комментарий над `_CITY_CENTROIDS_DEG`).
Город резолвится детерминированно по `payload.lat/lon` через
`_resolve_coverage_city` `city_hint` в payload остаётся только
информационным полем (см. `CoverageProbeInput.city_hint`), на результат
не влияет.
""" """
from app.services.estimator import _RN_DUP_WINDOW, MAX_ANALOGS_PER_ADDRESS from app.services.estimator import _RN_DUP_WINDOW, MAX_ANALOGS_PER_ADDRESS
@ -2666,7 +2719,6 @@ def coverage_probe(
f""" f"""
WITH base AS ( WITH base AS (
SELECT SELECT
city,
days_on_market, days_on_market,
row_number() OVER ( row_number() OVER (
PARTITION BY address ORDER BY scraped_at DESC PARTITION BY address ORDER BY scraped_at DESC
@ -2700,9 +2752,7 @@ def coverage_probe(
FILTER ( FILTER (
WHERE days_on_market IS NOT NULL WHERE days_on_market IS NOT NULL
AND days_on_market <= :max_age_days AND days_on_market <= :max_age_days
) AS median_age_days, ) AS median_age_days
mode() WITHIN GROUP (ORDER BY city)
FILTER (WHERE city IS NOT NULL) AS cohort_city
FROM base FROM base
WHERE rn_addr <= :max_per_addr WHERE rn_addr <= :max_per_addr
AND rn_dup = 1 AND rn_dup = 1
@ -2733,12 +2783,16 @@ def coverage_probe(
and n_with_age >= COVERAGE_MIN_AGE_SAMPLES and n_with_age >= COVERAGE_MIN_AGE_SAMPLES
else None else None
) )
cohort_city = row["cohort_city"] if row else None
city, threshold, supported = _resolve_coverage_city(payload.city_hint, cohort_city) city, threshold, supported = _resolve_coverage_city(payload.lat, payload.lon)
if not supported or n_listings == 0: if not supported or n_listings == 0:
status: Literal["ok", "thin", "not_covered"] = "not_covered" status: Literal["ok", "thin", "not_covered"] = "not_covered"
# Nit-fix (повторная проверка #2894): threshold неприменим при
# not_covered — см. CoverageProbeResponse.threshold docstring. Раньше
# поддерживаемый (по координатам) город с пустой когортой отдавал
# реальный порог (8/12) вместе с not_covered — противоречило докстрингу.
threshold = 0
elif n_listings >= threshold: elif n_listings >= threshold:
status = "ok" status = "ok"
else: else:

View file

@ -763,9 +763,18 @@ class CoverageProbeInput(BaseModel):
lat/lon координаты, уже разрезолвленные фронтом (тот же контракт, что lat/lon координаты, уже разрезолвленные фронтом (тот же контракт, что
TradeInEstimateInput.lat/lon geocode делает фронт/автокомплит, эта ручка TradeInEstimateInput.lat/lon geocode делает фронт/автокомплит, эта ручка
сама НИКОГО не геокодирует). city_hint опционально, из того же сама НИКОГО не геокодирует). Город (и, соответственно, порог ok/thin) для
автокомплита (см. TradeInEstimateInput.city_hint); без него город ответа резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon см.
резолвится best-effort из моды city найденной когорты. `app.api.v1.trade_in._resolve_coverage_city`.
city_hint ИНФОРМАЦИОННОЕ поле, на результат НЕ влияет (повторная проверка
#2894, 2026-08). Раньше оно участвовало в резолве города как фолбэк —
убрано вместе с модой `listings.city`: оба источника ненадёжны (`city_hint`
непроверенный клиентский вход, `listings.city` город свип-контекста
скрейпера, не адреса объявления, см. комментарий в trade_in.py). Поле
оставлено в схеме, потому что фронт его уже шлёт в других ручках того же
автокомплита (см. TradeInEstimateInput.city_hint) принимаем и молча
игнорируем, чтобы не ронять запрос лишней 422.
""" """
lat: float = Field(ge=-90, le=90) lat: float = Field(ge=-90, le=90)
@ -804,8 +813,14 @@ class CoverageProbeResponse(BaseModel):
маленьком n_with_age цифра "медиана" по 1-2 объявлениям не медиана. маленьком n_with_age цифра "медиана" по 1-2 объявлениям не медиана.
threshold n, начиная с которого статус переходит в "ok" для резолвленного threshold n, начиная с которого статус переходит в "ok" для резолвленного
города; 0, если город не входит ни в один список (порог неприменим города; 0 всегда, когда status == "not_covered" (порог неприменим ни для
статус в этом случае всегда "not_covered" вне зависимости от n_listings). города вне зелёного/жёлтого списка, ни для поддерживаемого города с пустой
когортой), НЕ только для неподдерживаемого города.
city резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon запроса (ближайший центроид из
зелёного/жёлтого списка в пределах `COVERAGE_CITY_MATCH_RADIUS_KM`), не из
`city_hint` и не из моды `listings.city` найденной когорты см.
`app.api.v1.trade_in._resolve_coverage_city`.
""" """
status: Literal["ok", "thin", "not_covered"] status: Literal["ok", "thin", "not_covered"]

View file

@ -70,8 +70,16 @@ tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhau
tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself
tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient
# MAJOR-1 fix, coverage probe (#2894, независимый ревью) — тот же `_live_session()`. # MAJOR-1 fix, coverage probe (#2894, независимый ревью) — тот же `live_session` fixture
# (self-skip через `_live_db_available()`, живёт только при реальном Postgres DSN).
# Проверяет, что novostroyki-строка / geo_precision='city'-строка / price_rub=0-строка # Проверяет, что novostroyki-строка / geo_precision='city'-строка / price_rub=0-строка
# физически не попадают в когорту (не только SQL-текст, который проверяется отдельным # физически не попадают в когорту (не только SQL-текст, который проверяется отдельным
# статическим тестом test_cohort_sql_excludes_* в этом же файле, идущим на обоих лэйнах). # статическим тестом test_cohort_sql_excludes_* в этом же файле, идущим на обоих лэйнах).
tests/test_coverage_probe_endpoint.py::test_major1_cohort_excludes_novostroyki_and_city_precision_live tests/test_coverage_probe_endpoint.py::test_major1_cohort_excludes_novostroyki_and_city_precision_live
# MAJOR-2 поведенческий пин (повторная проверка #2894) — та же `live_session` fixture.
# Ловит мутацию «убрать FILTER у percentile_cont, оставив у count(*)», которую
# текстовый тест test_max_age_outlier_days_passed_to_sql пропускал (подстрока
# `days_on_market <= :max_age_days` встречается в SQL дважды). На мок-лэйне
# (deploy-tradein.yml, DSN-заглушка) реальной БД нет — self-skip.
tests/test_coverage_probe_endpoint.py::test_max_age_outlier_excluded_from_median_live

View file

@ -4,15 +4,19 @@
видит, сколько похожих квартир продаётся рядом и как быстро они уходят, без видит, сколько похожих квартир продаётся рядом и как быстро они уходят, без
единой рублёвой цифры в ответе. Covers: единой рублёвой цифры в ответе. Covers:
- пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов - пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов
- пустая когорта (n=0) not_covered даже в поддерживаемом городе - пустая когорта (n=0) not_covered даже в поддерживаемом городе; threshold
принудительно 0 в этом случае (nit-fix, повторная проверка #2894)
- в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы) - в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы)
- MAJOR-1 (независимый ревью #2894): когорта пробы — sync с - MAJOR-1 (независимый ревью #2894): когорта пробы — sync с
estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city', estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city',
price_rub > 0), не шире когорты платного эстиматора price_rub > 0), не шире когорты платного эстиматора
- MAJOR-2: median_listing_age_days честно null при тонкой n_with_age выборке, - MAJOR-2: median_listing_age_days честно null при тонкой n_with_age выборке,
выбросы (> COVERAGE_MAX_AGE_DAYS) не тянут медиану выбросы (> COVERAGE_MAX_AGE_DAYS) не тянут медиану запинено ЖИВЫМ SQL
- MINOR: когортный город (мода) побеждает city_hint при расхождении клиент (см. test_max_age_outlier_excluded_from_median_live), не только подстрокой
не управляет порогом; неизвестный город не эхуется сырой строкой - Повторная проверка #2894 (2026-08): город резолвится ИСКЛЮЧИТЕЛЬНО по
lat/lon (ближайший центроид), НЕ по моде `listings.city` (город
свип-контекста скрейпера, не адреса объявления) и НЕ по `city_hint`
(непроверенный клиентский вход) см. app.api.v1.trade_in._resolve_coverage_city
""" """
from __future__ import annotations from __future__ import annotations
@ -57,18 +61,19 @@ def trade_in_app() -> FastAPI:
def _row( def _row(
n_listings: int, n_listings: int,
median_age_days: float | None, median_age_days: float | None,
cohort_city: str | None,
n_with_age: int | None = None, n_with_age: int | None = None,
) -> dict: ) -> dict:
"""Строка, которую coverage_probe читает через db.execute(...).mappings().fetchone(). """Строка, которую coverage_probe читает через db.execute(...).mappings().fetchone().
n_with_age по умолчанию = n_listings, если не задан явно (большинство старых n_with_age по умолчанию = n_listings, если не задан явно (большинство старых
тестов не проверяют MAJOR-2 отдельно сохраняем их поведение). тестов не проверяют MAJOR-2 отдельно сохраняем их поведение).
Повторная проверка #2894: строка больше не несёт cohort_city — город
резолвится по lat/lon запроса, не по SQL-агрегату (см. модуль-докстринг).
""" """
return { return {
"n_listings": n_listings, "n_listings": n_listings,
"median_age_days": median_age_days, "median_age_days": median_age_days,
"cohort_city": cohort_city,
"n_with_age": n_with_age if n_with_age is not None else n_listings, "n_with_age": n_with_age if n_with_age is not None else n_listings,
} }
@ -90,8 +95,22 @@ def _override(app: FastAPI, db: MagicMock) -> None:
app.dependency_overrides[get_db] = lambda: (yield db) app.dependency_overrides[get_db] = lambda: (yield db)
# Екатеринбург — совпадает (с точностью до сотен метров) с центроидом
# _CITY_CENTROIDS_DEG["Екатеринбург"], поэтому дефолтный payload детерминированно
# резолвится в зелёный город без доп. настройки координат в каждом тесте.
_BASE_PAYLOAD = {"lat": 56.8384, "lon": 60.6057, "rooms": 2, "area_m2": 50.0} _BASE_PAYLOAD = {"lat": 56.8384, "lon": 60.6057, "rooms": 2, "area_m2": 50.0}
# Координаты других городов из COVERAGE_GREEN/YELLOW_CITIES (те же значения, что
# _CITY_CENTROIDS_DEG в trade_in.py) — используются, когда тесту нужен НЕ ЕКБ.
_NIZHNY_TAGIL = {"lat": 57.9099, "lon": 59.9819}
_REVDA = {"lat": 56.7986, "lon": 59.9298}
_BEREZOVSKY = {"lat": 56.9096, "lon": 60.8034}
# Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил)
# в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно
# "город не определён", без совпадения ни с одним из 8 центроидов.
_FAR_AWAY_CITY = {"lat": 59.6047, "lon": 60.1970}
# ── Response schema: NO price anywhere (issue #2894 hard rule) ──────────────── # ── Response schema: NO price anywhere (issue #2894 hard rule) ────────────────
@ -109,7 +128,7 @@ def test_coverage_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) -> None: def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
"""Same guard but on a live serialized response (belt-and-suspenders).""" """Same guard but on a live serialized response (belt-and-suspenders)."""
db = _db_mock_returning(_row(10, 21.0, "Екатеринбург")) db = _db_mock_returning(_row(10, 21.0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
@ -125,7 +144,7 @@ def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) ->
def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None: def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
"""Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok.""" """Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok."""
db = _db_mock_returning(_row(8, 15.0, "Екатеринбург")) db = _db_mock_returning(_row(8, 15.0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
@ -143,7 +162,7 @@ def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None: def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
"""Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered.""" """Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered."""
db = _db_mock_returning(_row(7, 10.0, "Екатеринбург")) db = _db_mock_returning(_row(7, 10.0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
@ -158,15 +177,12 @@ def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None: def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
"""Нижний Тагил (жёлтый, порог 12) — n=12 → ok. cohort_city совпадает с hint.""" """Нижний Тагил (жёлтый, порог 12) — n=12 → ok. Город резолвится из lat/lon."""
db = _db_mock_returning(_row(12, 30.0, "Нижний Тагил")) db = _db_mock_returning(_row(12, 30.0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
resp = client.post( resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NIZHNY_TAGIL})
"/api/v1/trade-in/coverage",
json={**_BASE_PAYLOAD, "city_hint": "Нижний Тагил"},
)
data = resp.json() data = resp.json()
assert data["status"] == "ok" assert data["status"] == "ok"
assert data["threshold"] == 12 assert data["threshold"] == 12
@ -174,51 +190,57 @@ def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None: def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
"""Ревда, n=11 (< порог 12) → thin. Когорта пуста по городу → используем hint.""" """Ревда, n=11 (< порог 12) → thin."""
db = _db_mock_returning(_row(11, 40.0, None)) db = _db_mock_returning(_row(11, 40.0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "city_hint": "Ревда"}) resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_REVDA})
data = resp.json() data = resp.json()
assert data["status"] == "thin" assert data["status"] == "thin"
assert data["threshold"] == 12 assert data["threshold"] == 12
# ── City outside both lists → always not_covered ──────────────────────────────── # ── City outside all centroids → always not_covered ─────────────────────────────
def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None: def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None:
"""Город вне списков → not_covered независимо от n_listings (даже n=500).""" """Точка вне 25-км радиуса всех центроидов → not_covered независимо от n_listings
db = _db_mock_returning(_row(500, 5.0, "Серов")) (даже n=500)."""
db = _db_mock_returning(_row(500, 5.0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "city_hint": "Серов"}) resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY})
data = resp.json() data = resp.json()
assert data["status"] == "not_covered" assert data["status"] == "not_covered"
assert data["threshold"] == 0 assert data["threshold"] == 0
assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует
assert data["city"] == "" # MINOR: неизвестный город не эхуется сырой строкой assert data["city"] == "" # город не определён — не эхуется сырой строкой
# ── Empty cohort ────────────────────────────────────────────────────────────── # ── Empty cohort ──────────────────────────────────────────────────────────────
def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None: def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None:
"""n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее.""" """n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее.
db = _db_mock_returning(_row(0, None, None, n_with_age=0))
Nit-fix (повторная проверка #2894): threshold обязан быть 0, а не реальным
порогом города (8) при not_covered threshold "неприменим" по докстрингу
CoverageProbeResponse, независимо от ПРИЧИНЫ not_covered.
"""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
resp = client.post( resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "city_hint": "Екатеринбург"}
)
data = resp.json() data = resp.json()
assert data["status"] == "not_covered" assert data["status"] == "not_covered"
assert data["n_listings"] == 0 assert data["n_listings"] == 0
assert data["median_listing_age_days"] is None assert data["median_listing_age_days"] is None
assert data["n_with_age"] == 0 assert data["n_with_age"] == 0
assert data["city"] == "Екатеринбург" # город резолвится по координатам всегда
assert data["threshold"] == 0 # nit: не 8, хотя город поддерживаемый
def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None: def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None:
@ -235,63 +257,70 @@ def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None:
assert data["n_listings"] == 0 assert data["n_listings"] == 0
assert data["median_listing_age_days"] is None assert data["median_listing_age_days"] is None
assert data["n_with_age"] == 0 assert data["n_with_age"] == 0
# ── MINOR: cohort mode (реальные данные из БД) побеждает city_hint ──────────────
def test_cohort_mode_overrides_city_hint_on_mismatch(trade_in_app: FastAPI) -> None:
"""lat/lon в Серове + city_hint='Екатеринбург' — клиент не управляет порогом.
Когорта реально нашлась в Серове (cohort_city="Серов", город вне списков)
ответ обязан игнорировать спуфленный hint и не выдавать зелёный threshold=8.
Регресс на прод-инцидент из независимого ревью #2894.
"""
db = _db_mock_returning(_row(9, 12.0, "Серов"))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post(
"/api/v1/trade-in/coverage",
json={**_BASE_PAYLOAD, "city_hint": "Екатеринбург"},
)
data = resp.json()
assert data["city"] != "Екатеринбург"
assert data["status"] == "not_covered" # Серов вне зелёного/жёлтого списка
assert data["threshold"] == 0 assert data["threshold"] == 0
def test_city_hint_used_only_as_fallback_for_empty_cohort(trade_in_app: FastAPI) -> None: # ── Город резолвится ТОЛЬКО по координатам — не по listings.city, не по city_hint ──
"""Когорта пуста (cohort_city=None) — hint используется как фолбэк для display,
но status всё равно not_covered (n_listings=0), так что подмена без эффекта."""
db = _db_mock_returning(_row(0, None, None, n_with_age=0)) def test_city_resolved_from_coordinates_not_cohort_mode(trade_in_app: FastAPI) -> None:
"""Точка в Берёзовском → city='Берёзовский' (а не 'Екатеринбург').
Регресс на прод-замер (повторная проверка #2894): в радиусе 1000м вокруг
Берёзовского 90/90 строк listings имеют city='Екатеринбург' (город
свип-контекста скрейпера, миграция 196) старая логика (мода когорты)
отдала бы 'Екатеринбург'. Ручка больше НЕ читает cohort city из SQL вовсе.
"""
db = _db_mock_returning(_row(8, 5.0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
resp = client.post( resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY})
"/api/v1/trade-in/coverage",
json={**_BASE_PAYLOAD, "city_hint": "Екатеринбург"},
)
data = resp.json()
assert data["city"] == "Екатеринбург"
assert data["status"] == "not_covered"
def test_yo_fold_city_hint_matches(trade_in_app: FastAPI) -> None:
"""«Березовский» без ё должен резолвиться в тот же зелёный порог, что «Берёзовский»."""
db = _db_mock_returning(_row(8, 5.0, None, n_with_age=8))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post(
"/api/v1/trade-in/coverage",
json={**_BASE_PAYLOAD, "city_hint": "березовский"},
)
data = resp.json() data = resp.json()
assert data["city"] == "Берёзовский"
assert data["status"] == "ok" assert data["status"] == "ok"
assert data["threshold"] == 8 assert data["threshold"] == 8
def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None:
"""Точка за пределами 25 км от всех 8 центроидов → not_covered, city=""."""
db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db)
client = TestClient(trade_in_app)
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY})
data = resp.json()
assert data["status"] == "not_covered"
assert data["city"] == ""
assert data["threshold"] == 0
def test_city_hint_does_not_change_threshold_or_status(trade_in_app: FastAPI) -> None:
"""city_hint — чисто информационное поле (повторная проверка #2894): точка в
Берёзовском + city_hint='Екатеринбург' обязана резолвиться в Берёзовский
(threshold=8, зелёный порог оба города зелёные, поэтому дополнительно
проверяем n=8 ok именно для Берёзовского, а не подмену клиентом города).
"""
db_with_hint = _db_mock_returning(_row(8, 5.0))
_override(trade_in_app, db_with_hint)
client = TestClient(trade_in_app)
resp_with_hint = client.post(
"/api/v1/trade-in/coverage",
json={**_BASE_PAYLOAD, **_BEREZOVSKY, "city_hint": "Екатеринбург"},
)
db_without_hint = _db_mock_returning(_row(8, 5.0))
_override(trade_in_app, db_without_hint)
resp_without_hint = client.post(
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY}
)
data_with, data_without = resp_with_hint.json(), resp_without_hint.json()
assert data_with["city"] == data_without["city"] == "Берёзовский"
assert data_with["threshold"] == data_without["threshold"] == 8
assert data_with["status"] == data_without["status"] == "ok"
# ── MAJOR-2: median age — n_with_age threshold + outlier clamp ───────────────── # ── MAJOR-2: median age — n_with_age threshold + outlier clamp ─────────────────
@ -301,7 +330,7 @@ def test_median_age_null_below_min_age_samples(trade_in_app: FastAPI) -> None:
from app.api.v1.trade_in import COVERAGE_MIN_AGE_SAMPLES from app.api.v1.trade_in import COVERAGE_MIN_AGE_SAMPLES
assert COVERAGE_MIN_AGE_SAMPLES == 5 assert COVERAGE_MIN_AGE_SAMPLES == 5
db = _db_mock_returning(_row(20, 40.0, "Екатеринбург", n_with_age=2)) db = _db_mock_returning(_row(20, 40.0, n_with_age=2))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
@ -314,7 +343,7 @@ def test_median_age_null_below_min_age_samples(trade_in_app: FastAPI) -> None:
def test_median_age_present_at_min_age_samples_threshold(trade_in_app: FastAPI) -> None: def test_median_age_present_at_min_age_samples_threshold(trade_in_app: FastAPI) -> None:
"""n_with_age=5 (== порог) → median_listing_age_days отдаётся.""" """n_with_age=5 (== порог) → median_listing_age_days отдаётся."""
db = _db_mock_returning(_row(20, 40.0, "Екатеринбург", n_with_age=5)) db = _db_mock_returning(_row(20, 40.0, n_with_age=5))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
@ -326,11 +355,17 @@ def test_median_age_present_at_min_age_samples_threshold(trade_in_app: FastAPI)
def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None: def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None:
"""COVERAGE_MAX_AGE_DAYS=365 передаётся в SQL как параметр — выбросы (мёртвые """COVERAGE_MAX_AGE_DAYS=365 передаётся в SQL как параметр — выбросы (мёртвые
объявления) отсекаются percentile_cont FILTER на стороне БД, не в Python.""" объявления) отсекаются percentile_cont FILTER на стороне БД, не в Python.
Слабая (текстовая) проверка подстрока встречается в SQL ДВАЖДЫ (count и
percentile_cont), поэтому `assert "..." in sql_text` одна ловит только
"убрали оба FILTER", не "убрали один из двух". Реальный поведенческий пин
test_max_age_outlier_excluded_from_median_live ниже (живой Postgres).
"""
from app.api.v1.trade_in import COVERAGE_MAX_AGE_DAYS from app.api.v1.trade_in import COVERAGE_MAX_AGE_DAYS
assert COVERAGE_MAX_AGE_DAYS == 365 assert COVERAGE_MAX_AGE_DAYS == 365
db = _db_mock_returning(_row(0, None, None, n_with_age=0)) db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
@ -341,7 +376,10 @@ def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None:
assert params["max_age_days"] == 365 assert params["max_age_days"] == 365
sql_text = str(call_args[0][0]) sql_text = str(call_args[0][0])
assert "days_on_market <= :max_age_days" in sql_text # count==2: и в count(*) FILTER, и в percentile_cont(...) FILTER — обе нужны,
# чтобы n_with_age и median_listing_age_days считались по ОДНОМУ и тому же
# предикату (иначе честный n_with_age маскирует нечестный медианный расчёт).
assert sql_text.count("days_on_market <= :max_age_days") == 2
# ── DB dedup / cap params passed through ──────────────────────────────────────── # ── DB dedup / cap params passed through ────────────────────────────────────────
@ -349,7 +387,7 @@ def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None:
def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None: def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None:
"""SQL params: radius=1000 (строго), area ±15%, rooms exact.""" """SQL params: radius=1000 (строго), area ±15%, rooms exact."""
db = _db_mock_returning(_row(0, None, None, n_with_age=0)) db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
@ -378,7 +416,7 @@ def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI)
def test_cohort_sql_excludes_novostroyki(trade_in_app: FastAPI) -> None: def test_cohort_sql_excludes_novostroyki(trade_in_app: FastAPI) -> None:
"""Guard новостроек — sync с estimator._COMMON_WHERE (5460) / Tier W (5932).""" """Guard новостроек — sync с estimator._COMMON_WHERE (5460) / Tier W (5932)."""
db = _db_mock_returning(_row(0, None, None, n_with_age=0)) db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
@ -390,7 +428,7 @@ def test_cohort_sql_excludes_novostroyki(trade_in_app: FastAPI) -> None:
def test_cohort_sql_excludes_city_precision_geocodes(trade_in_app: FastAPI) -> None: def test_cohort_sql_excludes_city_precision_geocodes(trade_in_app: FastAPI) -> None:
"""geo_precision != 'city' — sync с estimator Tier W (5910/5945-5948, #769 Part E).""" """geo_precision != 'city' — sync с estimator Tier W (5910/5945-5948, #769 Part E)."""
db = _db_mock_returning(_row(0, None, None, n_with_age=0)) db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
@ -402,7 +440,7 @@ def test_cohort_sql_excludes_city_precision_geocodes(trade_in_app: FastAPI) -> N
def test_cohort_sql_excludes_zero_price(trade_in_app: FastAPI) -> None: def test_cohort_sql_excludes_zero_price(trade_in_app: FastAPI) -> None:
"""price_rub > 0 — sync с estimator._COMMON_WHERE (5441) / Tier W (5916).""" """price_rub > 0 — sync с estimator._COMMON_WHERE (5441) / Tier W (5916)."""
db = _db_mock_returning(_row(0, None, None, n_with_age=0)) db = _db_mock_returning(_row(0, None, n_with_age=0))
_override(trade_in_app, db) _override(trade_in_app, db)
client = TestClient(trade_in_app) client = TestClient(trade_in_app)
@ -412,31 +450,66 @@ def test_cohort_sql_excludes_zero_price(trade_in_app: FastAPI) -> None:
assert "price_rub > 0" in sql_text assert "price_rub > 0" in sql_text
# ── MAJOR-1 (real-DB variant): предикаты реально фильтруют, не только в тексте ── # ── Live-DB tests (self-skip без реальной Postgres+PostGIS) ────────────────────
# #
# Опциональный тест против настоящего Postgres (тот же паттерн self-skip, что # Опциональные тесты против настоящего Postgres (тот же паттерн self-skip, что
# test_gar_flats_loader.py::_live_session) — вставляет novostroyki-строку и # test_gar_flats_loader.py::_live_session) — требуют TEST_DATABASE_URL/
# строку с geo_precision='city' в когорту и проверяет, что они физически НЕ # DATABASE_URL, указывающий на реальную БД (не дефолтный localhost:5432/test-
# посчитаны. Требует TEST_DATABASE_URL/DATABASE_URL, указывающий на реальную # заглушку); иначе skip. В CI (ci-tradein.yml) этот DSN всегда живой Postgres+
# Postgres+PostGIS БД (не дефолтный localhost:5432/test-заглушку) — иначе skip. # PostGIS-контейнер.
#
# Fix (повторная проверка #2894): раньше `_live_session()` вызывался И в
# `pytest.mark.skipif(...)` (на этапе СБОРА тестов — соединение открывалось и
# никогда не закрывалось, при реальном DSN это утечка на КАЖДЫЙ импорт файла),
# И повторно внутри тела единственного live-теста. Теперь доступность БД
# проверяется отдельной дешёвой функцией с явным закрытием соединения
# (`_live_db_available`), а сама Session выдаётся pytest-фикстурой
# (`live_session`) с гарантированным close() в finally, а не ручным вызовом.
def _live_session(): # type: ignore[no-untyped-def] def _live_db_available() -> bool:
"""Дешёвая проверка доступности live-Postgres — соединение открывается и
СРАЗУ закрывается (`with engine.connect()`), никакого висящего ORM Session.
Используется только в `pytest.mark.skipif(...)`, который вычисляется на
этапе сбора тестов до фикстур.
"""
try: try:
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn: if not dsn or "localhost:5432/test" in dsn:
return None return False
engine = create_engine(dsn, future=True) engine = create_engine(dsn, future=True)
conn = engine.connect() try:
conn.execute(sa_text("SELECT 1")) with engine.connect() as conn:
conn.close() conn.execute(sa_text("SELECT 1"))
return sessionmaker(bind=engine, future=True)() return True
finally:
engine.dispose()
except Exception: except Exception:
return None return False
@pytest.fixture()
def live_session(): # type: ignore[no-untyped-def]
"""Session для live-Postgres тестов — гарантированно закрывается после теста
(rollback + close + dispose в finally), в отличие от прежнего ручного вызова
`_live_session()` внутри тела каждого теста."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
engine = create_engine(dsn, future=True)
session_factory = sessionmaker(bind=engine, future=True)
session = session_factory()
try:
yield session
finally:
session.rollback()
session.close()
engine.dispose()
# Координаты вне Свердловской обл. (реальные данные там ~56-60/58-64) — изолируют # Координаты вне Свердловской обл. (реальные данные там ~56-60/58-64) — изолируют
@ -445,58 +518,139 @@ def _live_session(): # type: ignore[no-untyped-def]
_LIVE_LAT, _LIVE_LON = 1.111, 2.222 _LIVE_LAT, _LIVE_LON = 1.111, 2.222
@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД") @pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД")
def test_major1_cohort_excludes_novostroyki_and_city_precision_live() -> None: def test_major1_cohort_excludes_novostroyki_and_city_precision_live(live_session) -> None: # type: ignore[no-untyped-def]
from sqlalchemy import text as sa_text from sqlalchemy import text as sa_text
from app.api.v1.trade_in import coverage_probe from app.api.v1.trade_in import coverage_probe
from app.schemas.trade_in import CoverageProbeInput from app.schemas.trade_in import CoverageProbeInput
db = _live_session() db = live_session
assert db is not None rows = [
try: # (source_url suffix, listing_segment, geo_precision, price_rub) — все
rows = [ # остальные поля общие: rooms=2, area_m2=50, is_active, scraped_at=NOW().
# (source_url suffix, listing_segment, geo_precision, price_rub) — все ("ok-vtorichka", None, None, 5_000_000), # counted
# остальные поля общие: rooms=2, area_m2=50, is_active, scraped_at=NOW(). ("bad-novostroyka", "novostroyki", None, 5_000_000), # excluded
("ok-vtorichka", None, None, 5_000_000), # counted ("bad-city-precision", None, "city", 5_000_000), # excluded
("bad-novostroyka", "novostroyki", None, 5_000_000), # excluded ("bad-zero-price", None, None, 0), # excluded
("bad-city-precision", None, "city", 5_000_000), # excluded ]
("bad-zero-price", None, None, 0), # excluded for suffix, segment, geo_precision, price in rows:
] url = f"https://test.invalid/coverage-major1-{suffix}"
for suffix, segment, geo_precision, price in rows: db.execute(
url = f"https://test.invalid/coverage-major1-{suffix}" sa_text(
db.execute( """
sa_text( INSERT INTO listings
""" (source, source_url, source_id, dedup_hash, address, lat, lon,
INSERT INTO listings rooms, area_m2, price_rub, is_active, scraped_at,
(source, source_url, source_id, dedup_hash, address, lat, lon, listing_segment, geo_precision)
rooms, area_m2, price_rub, is_active, scraped_at, VALUES
listing_segment, geo_precision) ('test', :url, :url, :url, 'test addr', :lat, :lon,
VALUES 2, 50.0, :price, true, NOW(), :segment, :geo_precision)
('test', :url, :url, :url, 'test addr', :lat, :lon, """
2, 50.0, :price, true, NOW(), :segment, :geo_precision) ),
""" {
), "url": url,
{ "lat": _LIVE_LAT,
"url": url, "lon": _LIVE_LON,
"lat": _LIVE_LAT, "price": price,
"lon": _LIVE_LON, "segment": segment,
"price": price, "geo_precision": geo_precision,
"segment": segment, },
"geo_precision": geo_precision, )
},
)
result = coverage_probe( result = coverage_probe(
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
) )
# Только первая (ok-vtorichka) строка должна попадать в когорту — # Только первая (ok-vtorichka) строка должна попадать в когорту —
# каждая следующая вставка не должна сдвигать счётчик. # каждая следующая вставка не должна сдвигать счётчик.
assert result.n_listings == 1, ( assert result.n_listings == 1, (
f"predicate regression: n_listings={result.n_listings} after inserting " f"predicate regression: n_listings={result.n_listings} after inserting "
f"{suffix!r} (segment={segment!r} geo_precision={geo_precision!r} " f"{suffix!r} (segment={segment!r} geo_precision={geo_precision!r} "
f"price={price}) — expected still 1 (only ok-vtorichka counted)" f"price={price}) — expected still 1 (only ok-vtorichka counted)"
) )
finally:
db.rollback()
db.close() @pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД")
def test_max_age_outlier_excluded_from_median_live(live_session) -> None: # type: ignore[no-untyped-def]
"""MAJOR-2 поведенческий пин (повторная проверка #2894).
Текстовый тест (test_max_age_outlier_days_passed_to_sql) проверял, что
подстрока `days_on_market <= :max_age_days` встречается в SQL но она там
ДВАЖДЫ (count и percentile_cont), и мутация «убрать FILTER у
percentile_cont, оставив у count» проходила зелёной: n_with_age (из count)
оставался честным, а percentile_cont без FILTER считал медиану по ВСЕМ
days_on_market, включая выбросы.
Вставляет когорту из 5 "нормальных" объявлений (days_on_market
4/6/8/10/12, честная медиана 8) и один выброс (days_on_market=4000,
> COVERAGE_MAX_AGE_DAYS=365). Проверяет, что после вставки выброса
n_with_age и median_listing_age_days НЕ меняются (выброс попадает только
в n_listings) с правильными двумя FILTER это так; без FILTER у
percentile_cont медиана сдвинулась бы 8 9 (percentile_cont(0.5) по
[4,6,8,10,12,4000] = среднее 3-го и 4-го отсортированных значений = 9).
"""
from sqlalchemy import text as sa_text
from app.api.v1.trade_in import coverage_probe
from app.schemas.trade_in import CoverageProbeInput
db = live_session
normal_ages = [4, 6, 8, 10, 12]
for i, age in enumerate(normal_ages):
url = f"https://test.invalid/coverage-major2-normal-{i}"
db.execute(
sa_text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, address, lat, lon,
rooms, area_m2, price_rub, is_active, scraped_at, days_on_market)
VALUES
('test', :url, :url, :url, :addr, :lat, :lon,
2, 50.0, 5000000, true, NOW(), :age)
"""
),
{
"url": url,
"addr": f"test addr coverage-major2-{i}",
"lat": _LIVE_LAT,
"lon": _LIVE_LON,
"age": age,
},
)
result = coverage_probe(
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
)
assert result.n_listings == 5
assert result.n_with_age == 5
assert result.median_listing_age_days == 8
outlier_url = "https://test.invalid/coverage-major2-outlier"
db.execute(
sa_text(
"""
INSERT INTO listings
(source, source_url, source_id, dedup_hash, address, lat, lon,
rooms, area_m2, price_rub, is_active, scraped_at, days_on_market)
VALUES
('test', :url, :url, :url, 'test addr coverage-major2-outlier', :lat, :lon,
2, 50.0, 5000000, true, NOW(), 4000)
"""
),
{"url": outlier_url, "lat": _LIVE_LAT, "lon": _LIVE_LON},
)
result_with_outlier = coverage_probe(
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
)
assert result_with_outlier.n_listings == 6 # выброс всё же попадает в n_listings
assert result_with_outlier.n_with_age == 5, (
f"MAJOR-2 regression: outlier (days_on_market=4000 > MAX=365) leaked into "
f"n_with_age={result_with_outlier.n_with_age} — count(*) FILTER пропал/сломан"
)
assert result_with_outlier.median_listing_age_days == 8, (
f"MAJOR-2 regression: median_listing_age_days="
f"{result_with_outlier.median_listing_age_days} shifted by outlier — "
f"percentile_cont(...) FILTER пропал (мутация «убрать FILTER у "
f"percentile_cont, оставив у count»)"
)