Резолвер города в пробе покрытия знал только 9 центроидов Свердловской области, поэтому любой московский адрес получал not_covered с пустым городом при живой когорте рядом. Замер на проде: точка Тверской, 59 объявлений в радиусе 1 км, статус not_covered, город пустой; контроль по Екатеринбургу — ok. Москве заведена сетка из 67 центроидов, выведенная кластеризацией нашего же корпуса (35 552 объявления Циан, ST_ClusterKMeans), плюс 32 отрицательные точки Подмосковья: они участвуют в конкурсе ближайшего центроида, но порога не имеют, поэтому граница с областью проходит по конкурсу центров, а не по окружности. Радиус стал свойством центроида. У свердловских точек прежние 25 км байт-в-байт, у московских 8 км: круги плотной сетки складываются, и общий 25-километровый радиус протекал вглубь области — Наро-Фоминск 9.83 км до сетки, Кубинка 17.18, Чехов 22.25, все резолвились как «Москва». Порог Москвы жёлтый (12), не зелёный: 200 случайных московских адресов дают медиану когорты 14 и долю с когортой не меньше 12 равную 0.57, против 37 и 0.865 у Екатеринбурга. Остаточная цена — 48 московских объявлений из 35 552 (0.14%) в приграничной полосе выигрываются подмосковным центром. Тесты: 29 контрольных районов Москвы резолвятся в «Москва», 32 города области дают «город не определён», резолв по Свердловской области сверен с прежней реализацией на решётке из 851 узла — расхождений 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
1434 lines
70 KiB
Python
1434 lines
70 KiB
Python
"""Tests for POST /api/v1/trade-in/coverage (issue #2894).
|
||
|
||
Бесплатная проба покрытия для публичного лэндинга «МЕРА» — до оплаты человек
|
||
видит, сколько похожих квартир продаётся рядом и как быстро они уходят, без
|
||
единой рублёвой цифры в ответе. Covers:
|
||
- пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов
|
||
- пустая когорта (n=0) → not_covered даже в поддерживаемом городе; threshold
|
||
принудительно 0 в этом случае (nit-fix, повторная проверка #2894)
|
||
- в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы)
|
||
- MAJOR-1 (независимый ревью #2894): когорта пробы — sync с
|
||
estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city',
|
||
price_rub > 0), не шире когорты платного эстиматора
|
||
- MAJOR-2: median_listing_age_days честно null при тонкой n_with_age выборке,
|
||
выбросы (> COVERAGE_MAX_AGE_DAYS) не тянут медиану — запинено ЖИВЫМ SQL
|
||
(см. 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
|
||
|
||
import os
|
||
import sys
|
||
from unittest.mock import MagicMock
|
||
|
||
# psycopg v3 driver required; stub DATABASE_URL before any app import.
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import
|
||
# (trade_in.py imports generate_trade_in_pdf at module load).
|
||
_wp_mock = MagicMock()
|
||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
||
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
||
|
||
import pytest # noqa: E402
|
||
from fastapi import FastAPI # noqa: E402
|
||
from fastapi.testclient import TestClient # noqa: E402
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.fixture()
|
||
def trade_in_app() -> FastAPI:
|
||
"""Minimal FastAPI app mounting only the trade-in router with DB overridden."""
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
from app.core.db import get_db
|
||
|
||
application = FastAPI()
|
||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||
|
||
def _override_db():
|
||
yield MagicMock()
|
||
|
||
application.dependency_overrides[get_db] = _override_db
|
||
return application
|
||
|
||
|
||
def _row(
|
||
n_listings: int,
|
||
median_age_days: float | None,
|
||
n_with_age: int | None = None,
|
||
) -> dict:
|
||
"""Строка, которую coverage_probe читает через db.execute(...).mappings().fetchone().
|
||
|
||
n_with_age по умолчанию = n_listings, если не задан явно (большинство старых
|
||
тестов не проверяют MAJOR-2 отдельно — сохраняем их поведение).
|
||
|
||
Повторная проверка #2894: строка больше не несёт cohort_city — город
|
||
резолвится по lat/lon запроса, не по SQL-агрегату (см. модуль-докстринг).
|
||
"""
|
||
return {
|
||
"n_listings": n_listings,
|
||
"median_age_days": median_age_days,
|
||
"n_with_age": n_with_age if n_with_age is not None else n_listings,
|
||
}
|
||
|
||
|
||
def _db_mock_returning(row: dict | None) -> MagicMock:
|
||
"""DB session mock — coverage_probe reads db.execute(...).mappings().fetchone()."""
|
||
db = MagicMock()
|
||
mapping_result = MagicMock()
|
||
mapping_result.fetchone.return_value = row
|
||
execute_result = MagicMock()
|
||
execute_result.mappings.return_value = mapping_result
|
||
db.execute.return_value = execute_result
|
||
return db
|
||
|
||
|
||
def _override(app: FastAPI, db: MagicMock) -> None:
|
||
from app.core.db import get_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}
|
||
|
||
# Координаты других городов из 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 центроидов.
|
||
# Тавда: ближайший из центроидов (Каменск-Уральский) в 271 км, то есть точка
|
||
# заведомо вне 25-км радиуса любого поддержанного города — при этом всё ещё
|
||
# Свердловская область, как и задумано тестами ниже.
|
||
#
|
||
# Раньше здесь стояла точка 59.6047/60.1970 — окрестности Серова, в 21 км от
|
||
# его центра. Она работала как «далеко от всех» лишь потому, что Серов не был
|
||
# поддержан: город предлагался в дропдауне на сайте, но отсутствовал в списках
|
||
# покрытия, и его житель получал «этот адрес вне области». Серов добавлен
|
||
# 16.08.2026, и фикстура переехала туда, где действительно далеко.
|
||
_FAR_AWAY_CITY = {"lat": 58.0424, "lon": 65.2711}
|
||
|
||
|
||
# ── Response schema: NO price anywhere (issue #2894 hard rule) ────────────────
|
||
|
||
_PRICE_LIKE_SUBSTRINGS = ("price", "cena", "цена", "rub", "₽", "cost")
|
||
|
||
|
||
def test_coverage_response_has_no_price_fields(trade_in_app: FastAPI) -> None:
|
||
"""Regression guard: response schema must never grow a price-shaped field."""
|
||
from app.schemas.trade_in import CoverageProbeResponse
|
||
|
||
field_names = set(CoverageProbeResponse.model_fields.keys())
|
||
offending = [f for f in field_names if any(sub in f.lower() for sub in _PRICE_LIKE_SUBSTRINGS)]
|
||
assert not offending, f"CoverageProbeResponse must not carry price fields: {offending}"
|
||
|
||
|
||
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)."""
|
||
db = _db_mock_returning(_row(10, 21.0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
offending = [k for k in data if any(sub in k.lower() for sub in _PRICE_LIKE_SUBSTRINGS)]
|
||
assert not offending, f"response body must not carry price fields: {offending} in {data}"
|
||
|
||
|
||
# ── Thresholds: green city ─────────────────────────────────────────────────────
|
||
|
||
|
||
def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
|
||
"""Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok."""
|
||
db = _db_mock_returning(_row(8, 15.0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["status"] == "ok"
|
||
assert data["n_listings"] == 8
|
||
assert data["threshold"] == 8
|
||
assert data["city"] == "Екатеринбург"
|
||
assert data["radius_m"] == 1000
|
||
assert data["median_listing_age_days"] == 15
|
||
assert data["n_with_age"] == 8
|
||
|
||
|
||
def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
|
||
"""Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered."""
|
||
db = _db_mock_returning(_row(7, 10.0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
data = resp.json()
|
||
assert data["status"] == "thin"
|
||
assert data["n_listings"] == 7
|
||
assert data["threshold"] == 8
|
||
|
||
|
||
# ── Thresholds: yellow city ─────────────────────────────────────────────────────
|
||
|
||
|
||
def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None:
|
||
"""Нижний Тагил (жёлтый, порог 12) — n=12 → ok. Город резолвится из lat/lon."""
|
||
db = _db_mock_returning(_row(12, 30.0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NIZHNY_TAGIL})
|
||
data = resp.json()
|
||
assert data["status"] == "ok"
|
||
assert data["threshold"] == 12
|
||
assert data["city"] == "Нижний Тагил"
|
||
|
||
|
||
def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None:
|
||
"""Ревда, n=11 (< порог 12) → thin."""
|
||
db = _db_mock_returning(_row(11, 40.0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_REVDA})
|
||
data = resp.json()
|
||
assert data["status"] == "thin"
|
||
assert data["threshold"] == 12
|
||
|
||
|
||
# ── City outside all centroids → always not_covered ─────────────────────────────
|
||
|
||
|
||
def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None:
|
||
"""Точка вне 25-км радиуса всех центроидов → not_covered независимо от n_listings
|
||
(даже n=500)."""
|
||
db = _db_mock_returning(_row(500, 5.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["threshold"] == 0
|
||
assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует
|
||
assert data["city"] == "" # город не определён — не эхуется сырой строкой
|
||
|
||
|
||
# ── Empty cohort ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None:
|
||
"""n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее.
|
||
|
||
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)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
data = resp.json()
|
||
assert data["status"] == "not_covered"
|
||
assert data["n_listings"] == 0
|
||
assert data["median_listing_age_days"] is None
|
||
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:
|
||
"""DB возвращает None (defensive — count(*) агрегат всегда даёт строку, но
|
||
coverage_probe обязан не падать, даже если mock/driver вернул пусто)."""
|
||
db = _db_mock_returning(None)
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["status"] == "not_covered"
|
||
assert data["n_listings"] == 0
|
||
assert data["median_listing_age_days"] is None
|
||
assert data["n_with_age"] == 0
|
||
assert data["threshold"] == 0
|
||
|
||
|
||
# ── Город резолвится ТОЛЬКО по координатам — не по listings.city, не по city_hint ──
|
||
|
||
|
||
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)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY})
|
||
data = resp.json()
|
||
assert data["city"] == "Берёзовский"
|
||
assert data["status"] == "ok"
|
||
assert data["threshold"] == 8
|
||
|
||
|
||
def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None:
|
||
"""Точка за пределами 25 км от всех центроидов → 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
|
||
|
||
|
||
# ── Москва: сетка из 67 центроидов, одно display-имя, жёлтый порог ───────────
|
||
|
||
# Кремль — исторический центр; в сетке ему соответствует точка «Тверской».
|
||
_MOSCOW_CENTER = {"lat": 55.7558, "lon": 37.6173}
|
||
# Ватутинки, ТиНАО: ~32 км от Кремля. Точка существует, чтобы ловить дальнюю
|
||
# от центра часть Новой Москвы, а не центральные ячейки сетки.
|
||
_NEW_MOSCOW = {"lat": 55.5008, "lon": 37.3800}
|
||
# Зеленоград: эксклав в ~37 км от Кремля и в ~46 км от ТиНАО — без собственных
|
||
# точек сетки был бы not_covered.
|
||
_ZELENOGRAD = {"lat": 55.9825, "lon": 37.1814}
|
||
|
||
# Контрольные точки ВНУТРИ МКАД (замер 11.09.2026). До перехода на сетку
|
||
# половина из них проигрывала подмосковному центроиду и отдавала «город не
|
||
# определён» — это и был дефект, ради которого сетка заведена. Проверяем
|
||
# ПОВЕДЕНИЕ резолвера на координатах, а не содержимое константы.
|
||
_INSIDE_MKAD = {
|
||
"Новогиреево": (55.7520, 37.8140),
|
||
"Некрасовка": (55.7020, 37.9200),
|
||
"Тушино": (55.8267, 37.4358),
|
||
"Северное Бутово": (55.5680, 37.5700),
|
||
"Солнцево": (55.6350, 37.3900),
|
||
"Ясенево": (55.6060, 37.5330),
|
||
"Митино": (55.8450, 37.3630),
|
||
"Марьино": (55.6500, 37.7440),
|
||
"Измайлово": (55.7890, 37.7510),
|
||
"Бирюлёво Восточное": (55.5900, 37.6800),
|
||
"Печатники": (55.6900, 37.7300),
|
||
"Куркино": (55.8925, 37.3810),
|
||
}
|
||
# Владимирская область, ~180 км от Кремля и ~200 км от ТиНАО: «посреди поля»
|
||
# уже с московской стороны страны — новые центроиды не должны были расширить
|
||
# покрытие вглубь ЦФО.
|
||
_FAR_FROM_MOSCOW = {"lat": 55.5, "lon": 40.5}
|
||
|
||
|
||
def test_moscow_center_ok_at_yellow_threshold(trade_in_app: FastAPI) -> None:
|
||
"""Центр Москвы → city='Москва', порог 12 (жёлтый тир), n=12 → ok."""
|
||
db = _db_mock_returning(_row(12, 20.0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_MOSCOW_CENTER})
|
||
assert resp.status_code == 200, resp.text
|
||
data = resp.json()
|
||
assert data["city"] == "Москва"
|
||
assert data["threshold"] == 12
|
||
assert data["status"] == "ok"
|
||
|
||
|
||
def test_moscow_is_yellow_not_green(trade_in_app: FastAPI) -> None:
|
||
"""n=8 в Москве — это thin, а не ok: тир жёлтый (замер 10.09.2026, медиана
|
||
когорты 14 против 37 у зелёного Екатеринбурга, доля когорт >= 12 — 0.57)."""
|
||
db = _db_mock_returning(_row(8, 20.0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_MOSCOW_CENTER})
|
||
data = resp.json()
|
||
assert data["status"] == "thin"
|
||
assert data["threshold"] == 12
|
||
|
||
|
||
def test_new_moscow_resolves_to_display_name_moscow(trade_in_app: FastAPI) -> None:
|
||
"""ТиНАО ловится своей точкой сетки, но наружу уходит «Москва».
|
||
|
||
Ключ центроида («Москва (Ватутинки, НАО)») наружу не просачивается — иначе
|
||
повторился бы баг Берёзовского: человек видит в ответе чужое название.
|
||
"""
|
||
db = _db_mock_returning(_row(12, 25.0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NEW_MOSCOW})
|
||
data = resp.json()
|
||
assert data["city"] == "Москва"
|
||
assert data["threshold"] == 12
|
||
assert data["status"] == "ok"
|
||
|
||
|
||
def test_zelenograd_resolves_to_display_name_moscow(trade_in_app: FastAPI) -> None:
|
||
"""Зеленоград — свои точки сетки, тоже с display-именем «Москва»."""
|
||
db = _db_mock_returning(_row(12, 25.0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_ZELENOGRAD})
|
||
data = resp.json()
|
||
assert data["city"] == "Москва"
|
||
assert data["threshold"] == 12
|
||
|
||
|
||
def test_inside_mkad_points_resolve_to_moscow(trade_in_app: FastAPI) -> None:
|
||
"""Все контрольные точки внутри МКАД → «Москва» с жёлтым порогом 12.
|
||
|
||
Регрессия на дефект «сетки нет»: с тремя центроидами Митино, Марьино,
|
||
Печатники, Некрасовка и Новогиреево проигрывали подмосковным точкам
|
||
(Химки, Реутов, Развилка) и отдавали «город не определён».
|
||
"""
|
||
from app.api.v1.trade_in import _resolve_coverage_city
|
||
|
||
for name, (lat, lon) in _INSIDE_MKAD.items():
|
||
assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True), (
|
||
f"{name} внутри МКАД, но резолвится не в Москву"
|
||
)
|
||
|
||
|
||
def test_inside_mkad_points_answer_through_the_endpoint(trade_in_app: FastAPI) -> None:
|
||
"""Те же точки через ручку: 200, city='Москва', threshold=12."""
|
||
db = _db_mock_returning(_row(12, 20.0))
|
||
_override(trade_in_app, db)
|
||
client = TestClient(trade_in_app)
|
||
|
||
for name, (lat, lon) in _INSIDE_MKAD.items():
|
||
resp = client.post(
|
||
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon}
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
data = resp.json()
|
||
assert data["city"] == "Москва", name
|
||
assert data["threshold"] == 12, name
|
||
assert data["status"] == "ok", name
|
||
|
||
|
||
def test_every_moscow_grid_point_resolves_to_moscow() -> None:
|
||
"""В каждой точке сетки резолвер отдаёт «Москву» — мёртвых точек нет.
|
||
|
||
Точка, у которой ближайшим оказался подмосковный центроид, была бы мёртвой
|
||
строкой: её кластер объявлений всё равно ушёл бы в «город не определён».
|
||
"""
|
||
from app.api.v1.trade_in import _MOSCOW_GRID_DEG, _resolve_coverage_city
|
||
|
||
assert len(_MOSCOW_GRID_DEG) == 67
|
||
for key, (lat, lon) in _MOSCOW_GRID_DEG.items():
|
||
assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True), key
|
||
|
||
|
||
def test_far_from_moscow_centroids_still_not_covered(trade_in_app: FastAPI) -> None:
|
||
"""Точка посреди поля в 180 км от Москвы → not_covered, city=""."""
|
||
db = _db_mock_returning(_row(40, 30.0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_FROM_MOSCOW})
|
||
data = resp.json()
|
||
assert data["status"] == "not_covered"
|
||
assert data["city"] == ""
|
||
assert data["threshold"] == 0
|
||
|
||
|
||
def test_every_centroid_key_has_a_threshold() -> None:
|
||
"""Центроид без порога — это KeyError и 500 в ручке, а не «город не найден».
|
||
|
||
Инвариант появился вместе с московскими центроидами: ключ центроида и
|
||
display-имя города теперь разные вещи, и промах в одном из трёх ключей
|
||
молча превратился бы в падение ручки на реальном московском адресе.
|
||
"""
|
||
from app.api.v1.trade_in import (
|
||
_CITY_CENTROIDS_DEG,
|
||
_COVERAGE_CITY_THRESHOLDS,
|
||
_fold_city,
|
||
)
|
||
|
||
for key in _CITY_CENTROIDS_DEG:
|
||
assert _fold_city(key) in _COVERAGE_CITY_THRESHOLDS, (
|
||
f"центроид {key} не имеет порога — резолв по координатам упадёт с KeyError"
|
||
)
|
||
|
||
|
||
def test_all_moscow_centroids_share_one_display_and_threshold() -> None:
|
||
"""Все 56 ключей сетки → одно имя «Москва» и один порог 12.
|
||
|
||
Проверяем не список координат, а результат резолва в каждой точке сетки:
|
||
ключ («Москва (Митино)» и т.п.) наружу просочиться не должен.
|
||
"""
|
||
from app.api.v1.trade_in import (
|
||
_CITY_CENTROIDS_DEG,
|
||
_COVERAGE_CITY_THRESHOLDS,
|
||
COVERAGE_MOSCOW_CENTROID_KEYS,
|
||
COVERAGE_MOSCOW_MIN_N,
|
||
_fold_city,
|
||
_resolve_coverage_city,
|
||
)
|
||
|
||
assert COVERAGE_MOSCOW_MIN_N == 12
|
||
assert len(COVERAGE_MOSCOW_CENTROID_KEYS) == 67
|
||
assert len(set(COVERAGE_MOSCOW_CENTROID_KEYS)) == 67, "ключи сетки должны быть уникальны"
|
||
for key in COVERAGE_MOSCOW_CENTROID_KEYS:
|
||
assert _COVERAGE_CITY_THRESHOLDS[_fold_city(key)] == ("Москва", 12)
|
||
lat, lon = _CITY_CENTROIDS_DEG[key]
|
||
assert _resolve_coverage_city(lat, lon) == ("Москва", 12, True)
|
||
|
||
|
||
def test_sverdlovsk_resolution_untouched_by_moscow_centroids(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Свердловская область не изменилась ни на байт после добавления Москвы.
|
||
|
||
Сравниваем резолв на полной таблице центроидов с резолвом на той же
|
||
таблице БЕЗ московских ключей — по всем свердловским центроидам, по
|
||
нескольким точкам между ними и по заведомо далёкой точке.
|
||
"""
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
|
||
full = trade_in_module._CITY_CENTROIDS_DEG
|
||
moscow_keys = {
|
||
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
||
}
|
||
without_moscow = {
|
||
k: v for k, v in full.items() if trade_in_module._fold_city(k) not in moscow_keys
|
||
}
|
||
assert len(without_moscow) == len(full) - 67
|
||
|
||
points = [
|
||
*without_moscow.values(),
|
||
(56.75, 60.70), # южная окраина Екатеринбурга
|
||
(56.95, 60.65), # между ЕКБ и Верхней Пышмой
|
||
(57.35, 60.20), # между Тагилом и ЕКБ — вне радиуса обоих
|
||
(58.0424, 65.2711), # Тавда, далеко от всего
|
||
]
|
||
|
||
after = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
|
||
monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", without_moscow)
|
||
before = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
|
||
|
||
assert after == before
|
||
|
||
|
||
# ── Подмосковье: отрицательные центроиды забирают область у Москвы ────────────
|
||
|
||
# Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов
|
||
# (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву».
|
||
_MOSCOW_OBLAST_CITIES = {
|
||
"Химки": (55.8894, 37.4450),
|
||
"Мытищи": (55.9116, 37.7308),
|
||
"Люберцы": (55.6767, 37.8931),
|
||
"Балашиха": (55.7969, 37.9386),
|
||
"Красногорск": (55.8317, 37.3300),
|
||
"Одинцово": (55.6789, 37.2639),
|
||
"Подольск": (55.4312, 37.5450),
|
||
"Реутов": (55.7614, 37.8564),
|
||
"Королёв": (55.9142, 37.8256),
|
||
"Долгопрудный": (55.9386, 37.5100),
|
||
"Видное": (55.5519, 37.7133),
|
||
"Железнодорожный": (55.7444, 38.0128),
|
||
"Лобня": (56.0100, 37.4750),
|
||
"Дзержинский": (55.6294, 37.8500),
|
||
"Котельники": (55.6553, 37.8619),
|
||
"Истра": (55.9167, 36.8667),
|
||
}
|
||
|
||
|
||
def test_moscow_oblast_cities_resolve_to_no_city() -> None:
|
||
"""Подмосковье → «город не определён»: данных по области мы не собираем."""
|
||
from app.api.v1.trade_in import _resolve_coverage_city
|
||
|
||
for name, (lat, lon) in _MOSCOW_OBLAST_CITIES.items():
|
||
assert _resolve_coverage_city(lat, lon) == ("", 0, False), (
|
||
f"{name} — Московская область, но резолвится в город"
|
||
)
|
||
|
||
|
||
def test_every_negative_centroid_resolves_to_no_city() -> None:
|
||
"""Каждая точка списка отрицательных центроидов — не город.
|
||
|
||
Проверяем ВЕСЬ список, а не выборку: точка, случайно оказавшаяся ближе
|
||
к московскому центроиду, чем к себе самой, была бы мёртвой строкой.
|
||
"""
|
||
from app.api.v1.trade_in import (
|
||
_COVERAGE_NEGATIVE_CENTROIDS_DEG,
|
||
_resolve_coverage_city,
|
||
)
|
||
|
||
assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 32
|
||
for name, (lat, lon) in _COVERAGE_NEGATIVE_CENTROIDS_DEG.items():
|
||
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name
|
||
|
||
|
||
def test_negative_centroids_have_no_threshold_and_no_key_collision() -> None:
|
||
"""Отрицательные центроиды не должны попасть в таблицу порогов.
|
||
|
||
И не должны пересекаться по ключу с настоящими городами — иначе один
|
||
словарь молча перекрыл бы другой в конкурсе ближайшего.
|
||
"""
|
||
from app.api.v1.trade_in import (
|
||
_CITY_CENTROIDS_DEG,
|
||
_COVERAGE_CITY_THRESHOLDS,
|
||
_COVERAGE_NEGATIVE_CENTROIDS_DEG,
|
||
_fold_city,
|
||
)
|
||
|
||
negative = {_fold_city(k) for k in _COVERAGE_NEGATIVE_CENTROIDS_DEG}
|
||
positive = {_fold_city(k) for k in _CITY_CENTROIDS_DEG}
|
||
|
||
assert negative & positive == set()
|
||
assert negative & set(_COVERAGE_CITY_THRESHOLDS) == set()
|
||
|
||
|
||
def test_moscow_itself_survives_negative_centroids(trade_in_app: FastAPI) -> None:
|
||
"""Опорные московские точки по-прежнему дают «Москву» и жёлтый порог."""
|
||
from app.api.v1.trade_in import _resolve_coverage_city
|
||
|
||
for point in (_MOSCOW_CENTER, _NEW_MOSCOW, _ZELENOGRAD):
|
||
assert _resolve_coverage_city(point["lat"], point["lon"]) == ("Москва", 12, True)
|
||
|
||
db = _db_mock_returning(_row(12, 20.0))
|
||
_override(trade_in_app, db)
|
||
client = TestClient(trade_in_app)
|
||
for point in (_MOSCOW_CENTER, _NEW_MOSCOW, _ZELENOGRAD):
|
||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **point})
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["city"] == "Москва"
|
||
|
||
|
||
def test_moscow_oblast_city_does_not_500_on_the_endpoint(trade_in_app: FastAPI) -> None:
|
||
"""Химки через ручку: 200 и not_covered, а не 500 от KeyError по порогу."""
|
||
db = _db_mock_returning(_row(40, 30.0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
lat, lon = _MOSCOW_OBLAST_CITIES["Химки"]
|
||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon})
|
||
assert resp.status_code == 200, resp.text
|
||
data = resp.json()
|
||
assert data["status"] == "not_covered"
|
||
assert data["city"] == ""
|
||
assert data["threshold"] == 0
|
||
|
||
|
||
def test_nearest_centroid_without_threshold_does_not_break_the_endpoint(
|
||
trade_in_app: FastAPI,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Ближайший центроид без порога → «город не определён», а не 500.
|
||
|
||
Раньше порог брался прямым индексированием `_COVERAGE_CITY_THRESHOLDS`,
|
||
и любой центроид без порога (отрицательный или просто забытый при
|
||
добавлении города) ронял ручку на живом адресе.
|
||
"""
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
|
||
monkeypatch.setattr(
|
||
trade_in_module,
|
||
"_CITY_CENTROIDS_DEG",
|
||
{
|
||
**trade_in_module._CITY_CENTROIDS_DEG,
|
||
"Город Без Порога": (_BASE_PAYLOAD["lat"], _BASE_PAYLOAD["lon"]),
|
||
},
|
||
)
|
||
assert trade_in_module._resolve_coverage_city(_BASE_PAYLOAD["lat"], _BASE_PAYLOAD["lon"]) == (
|
||
"",
|
||
0,
|
||
False,
|
||
)
|
||
|
||
db = _db_mock_returning(_row(40, 30.0))
|
||
_override(trade_in_app, db)
|
||
resp = TestClient(trade_in_app).post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["status"] == "not_covered"
|
||
assert resp.json()["city"] == ""
|
||
|
||
|
||
def test_sverdlovsk_resolution_untouched_by_negative_centroids(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Свердловская область не изменилась ни на байт от подмосковных точек.
|
||
|
||
Сравниваем резолв на полной таблице с резолвом БЕЗ отрицательных
|
||
центроидов — по всем свердловским центроидам, точкам между ними и заведомо
|
||
далёкой точке. Плюс прямая проверка расстояний: ближайшая подмосковная
|
||
точка к любому свердловскому центроиду дальше, чем сам центроид (0 км).
|
||
"""
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
|
||
sverdlovsk = {
|
||
k: v
|
||
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
||
if trade_in_module._fold_city(k)
|
||
not in {
|
||
trade_in_module._fold_city(m) for m in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
||
}
|
||
}
|
||
assert len(sverdlovsk) == 9
|
||
|
||
points = [
|
||
*sverdlovsk.values(),
|
||
(56.75, 60.70), # южная окраина Екатеринбурга
|
||
(56.95, 60.65), # между ЕКБ и Верхней Пышмой
|
||
(57.35, 60.20), # между Тагилом и ЕКБ — вне радиуса обоих
|
||
(58.0424, 65.2711), # Тавда, далеко от всего
|
||
]
|
||
|
||
after = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
|
||
monkeypatch.setattr(trade_in_module, "_COVERAGE_NEGATIVE_CENTROIDS_DEG", {})
|
||
before = [trade_in_module._resolve_coverage_city(lat, lon) for lat, lon in points]
|
||
|
||
assert after == before
|
||
|
||
negatives = dict(_MOSCOW_OBLAST_CITIES)
|
||
for city, (clat, clon) in sverdlovsk.items():
|
||
nearest_negative_km = min(
|
||
trade_in_module._haversine_km(clat, clon, nlat, nlon)
|
||
for nlat, nlon in negatives.values()
|
||
)
|
||
assert nearest_negative_km > trade_in_module.COVERAGE_CITY_MATCH_RADIUS_KM, (
|
||
f"подмосковная точка в {nearest_negative_km:.0f} км от {city}"
|
||
)
|
||
|
||
|
||
# ── Радиус как свойство центроида: контрольные наборы замера 11.09.2026 ──────
|
||
|
||
# Московский контрольный набор (29 адресов). Не выборка «для красоты»: сюда
|
||
# входят все четыре точки, которые в замере оказались худшими по запасу до
|
||
# сетки (Рублёво 4.05 км, Внуково 3.64, Медведково 2.78, Бибирево 2.75),
|
||
# анклавы (Зеленоград, Восточный, Куркино, Капотня, Новокосино) и по одному
|
||
# адресу с каждой стороны МКАД. Проверяется ПОВЕДЕНИЕ резолвера на
|
||
# координатах, а не содержимое констант.
|
||
_MOSCOW_CONTROLS = {
|
||
"Тверской": (55.7558, 37.6173),
|
||
"Рублёво": (55.7530, 37.3680),
|
||
"Внуково": (55.6270, 37.2860),
|
||
"Северное Медведково": (55.8880, 37.6480),
|
||
"Бибирево": (55.8930, 37.5900),
|
||
"Капотня": (55.6390, 37.7950),
|
||
"Новокосино": (55.7400, 37.8600),
|
||
"Куркино": (55.8925, 37.3810),
|
||
"Восточный": (55.8130, 37.8600),
|
||
"Ховрино": (55.8730, 37.4870),
|
||
"Северное Тушино": (55.8560, 37.4390),
|
||
"Митино": (55.8450, 37.3630),
|
||
"Зеленоград": (55.9825, 37.1814),
|
||
"Ватутинки (НАО)": (55.5008, 37.3800),
|
||
"Некрасовка": (55.7020, 37.9200),
|
||
"Новогиреево": (55.7520, 37.8140),
|
||
"Ивановское": (55.7750, 37.8330),
|
||
"Жулебино": (55.6900, 37.8550),
|
||
"Марьино": (55.6500, 37.7440),
|
||
"Братеево": (55.6350, 37.7400),
|
||
"Орехово-Борисово Южное": (55.6000, 37.7250),
|
||
"Бирюлёво Восточное": (55.5900, 37.6800),
|
||
"Северное Бутово": (55.5680, 37.5700),
|
||
"Ясенево": (55.6060, 37.5330),
|
||
"Солнцево": (55.6350, 37.3900),
|
||
"Можайский": (55.7050, 37.4000),
|
||
"Строгино": (55.8020, 37.4020),
|
||
"Северный": (55.9250, 37.5450),
|
||
"Троицк (ТАО)": (55.4853, 37.3050),
|
||
}
|
||
|
||
# Областной контрольный набор (32 города). 16 из них имеют собственный
|
||
# отрицательный центроид и выигрывают им; остальные 16 держатся только
|
||
# радиусом московских точек — включая Наро-Фоминск (9.95 км до ближайшей
|
||
# точки сетки), Кубинку (17.2) и Чехов (22.3), которые при глобальных 25 км
|
||
# резолвились как «Москва». Это и есть дефект #1.
|
||
_MOSCOW_OBLAST_CONTROLS = {
|
||
**_MOSCOW_OBLAST_CITIES,
|
||
"Наро-Фоминск": (55.3869, 36.7331),
|
||
"Кубинка": (55.5794, 36.7000),
|
||
"Чехов": (55.1477, 37.4770),
|
||
"Голицыно": (55.6167, 36.9833),
|
||
"Лыткарино": (55.5833, 37.9000),
|
||
"Домодедово": (55.4400, 37.7594),
|
||
"Жуковский": (55.5952, 38.1200),
|
||
"Щёлково": (55.9200, 38.0000),
|
||
"Пушкино": (56.0100, 37.8500),
|
||
"Звенигород": (55.7300, 36.8500),
|
||
"Апрелевка": (55.5500, 37.0700),
|
||
"Селятино": (55.5081, 36.9825),
|
||
"Немчиновка": (55.7050, 37.3450),
|
||
"Развилка": (55.5842, 37.7392),
|
||
"Томилино": (55.6528, 37.9472),
|
||
"Барвиха": (55.7333, 37.2333),
|
||
}
|
||
|
||
# Точки, добавленные замером 11.09.2026: жилые районы, целиком проигрывавшие
|
||
# конкурс подмосковной точке. Проверяются по координатам из самого модуля,
|
||
# ключ здесь — только адрес строки.
|
||
_ADDED_GRID_KEYS = (
|
||
"Москва (Восточный)",
|
||
"Москва (Левобережный)",
|
||
"Москва (Орехово-Борисово Южное)",
|
||
"Москва (Северный)",
|
||
"Москва (Митино-запад)",
|
||
"Москва (Ивановское)",
|
||
"Москва (Жулебино)",
|
||
"Москва (Можайский-запад)",
|
||
"Москва (Новокосино)",
|
||
"Москва (Капотня)",
|
||
"Москва (Куркино)",
|
||
"Москва (Некрасовка-восток)",
|
||
)
|
||
|
||
# Артефакт геокода, выброшенный из сетки: адрес «ВАО, р-н Восточный, 4»,
|
||
# посаженный геокодером на 22 км мимо настоящего Восточного и раздававший
|
||
# имя «Москва» Пушкино в 3.06 км.
|
||
_PHANTOM_EXCLAVE = (56.0087, 37.7960)
|
||
|
||
|
||
def _legacy_resolve(lat: float, lon: float, radius_km: float = 25.0) -> tuple[str, int, bool]:
|
||
"""Прежний резолвер: тот же конкурс ближайшего, но ОДИН глобальный радиус.
|
||
|
||
Нужен, чтобы тесты сравнивали ПОВЕДЕНИЕ, а не пересказывали константы:
|
||
им проверяется и что дефект #1 действительно существовал, и что
|
||
Свердловская область от перехода на радиус-свойство не сдвинулась.
|
||
"""
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
|
||
nearest, nearest_km = None, float("inf")
|
||
for city, (clat, clon) in (
|
||
*trade_in_module._CITY_CENTROIDS_DEG.items(),
|
||
*trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
|
||
):
|
||
distance_km = trade_in_module._haversine_km(lat, lon, clat, clon)
|
||
if distance_km < nearest_km:
|
||
nearest_km, nearest = distance_km, city
|
||
if nearest is None or nearest_km > radius_km:
|
||
return "", 0, False
|
||
entry = trade_in_module._COVERAGE_CITY_THRESHOLDS.get(trade_in_module._fold_city(nearest))
|
||
return ("", 0, False) if entry is None else (entry[0], entry[1], True)
|
||
|
||
|
||
def test_whole_moscow_control_set_resolves_to_moscow() -> None:
|
||
"""Все 29 московских контролей → «Москва», жёлтый порог 12."""
|
||
from app.api.v1.trade_in import _resolve_coverage_city
|
||
|
||
assert len(_MOSCOW_CONTROLS) == 29
|
||
failed = {
|
||
name: _resolve_coverage_city(lat, lon)
|
||
for name, (lat, lon) in _MOSCOW_CONTROLS.items()
|
||
if _resolve_coverage_city(lat, lon) != ("Москва", 12, True)
|
||
}
|
||
assert failed == {}
|
||
|
||
|
||
def test_whole_moscow_control_set_answers_through_the_endpoint(trade_in_app: FastAPI) -> None:
|
||
"""Те же 29 адресов через ручку: 200, city='Москва', threshold=12."""
|
||
db = _db_mock_returning(_row(12, 20.0))
|
||
_override(trade_in_app, db)
|
||
client = TestClient(trade_in_app)
|
||
|
||
for name, (lat, lon) in _MOSCOW_CONTROLS.items():
|
||
resp = client.post(
|
||
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon}
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["city"] == "Москва", name
|
||
assert resp.json()["threshold"] == 12, name
|
||
|
||
|
||
def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastAPI) -> None:
|
||
"""Все 32 областных контроля → «город не определён», и ручка отдаёт 200."""
|
||
from app.api.v1.trade_in import _resolve_coverage_city
|
||
|
||
assert len(_MOSCOW_OBLAST_CONTROLS) == 32
|
||
failed = {
|
||
name: _resolve_coverage_city(lat, lon)
|
||
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items()
|
||
if _resolve_coverage_city(lat, lon) != ("", 0, False)
|
||
}
|
||
assert failed == {}
|
||
|
||
db = _db_mock_returning(_row(40, 30.0))
|
||
_override(trade_in_app, db)
|
||
client = TestClient(trade_in_app)
|
||
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items():
|
||
resp = client.post(
|
||
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon}
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["status"] == "not_covered", name
|
||
assert resp.json()["city"] == "", name
|
||
|
||
|
||
def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
|
||
"""Дефект #1 предметно: Наро-Фоминск, Кубинка, Чехов.
|
||
|
||
С одним глобальным радиусом 25 км круги плотной сетки складывались, и все
|
||
трое получали «Москву», хотя до ближайшей точки сетки им 9.95, 17.2 и
|
||
22.3 км. С радиусом-свойством (8 км у московских точек) — «не определён».
|
||
"""
|
||
from app.api.v1.trade_in import _resolve_coverage_city
|
||
|
||
for name in ("Наро-Фоминск", "Кубинка", "Чехов"):
|
||
lat, lon = _MOSCOW_OBLAST_CONTROLS[name]
|
||
assert _legacy_resolve(lat, lon) == ("Москва", 12, True), (
|
||
f"{name}: тест перестал воспроизводить дефект, сравнивать не с чем"
|
||
)
|
||
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name
|
||
|
||
|
||
def test_radius_is_a_property_of_the_centroid_not_a_global_constant() -> None:
|
||
"""Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской.
|
||
|
||
Наро-Фоминск в 9.95 км от ближайшей точки московской сетки — «не
|
||
определён». Точка в 20 км южнее центра Екатеринбурга (то есть ДАЛЬШЕ) —
|
||
«Екатеринбург». Глобальной константой такое поведение невыразимо.
|
||
"""
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
|
||
lat, lon = _MOSCOW_OBLAST_CONTROLS["Наро-Фоминск"]
|
||
km_to_grid = min(
|
||
trade_in_module._haversine_km(lat, lon, glat, glon)
|
||
for glat, glon in trade_in_module._MOSCOW_GRID_DEG.values()
|
||
)
|
||
assert 8.0 < km_to_grid < 25.0, f"{km_to_grid:.2f} км — контроль потерял смысл"
|
||
assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False)
|
||
|
||
ekb_lat, ekb_lon = trade_in_module._CITY_CENTROIDS_DEG["Екатеринбург"]
|
||
south_lat = ekb_lat - 20.0 / 111.19
|
||
km_to_ekb = trade_in_module._haversine_km(south_lat, ekb_lon, ekb_lat, ekb_lon)
|
||
assert km_to_ekb > km_to_grid, "свердловская проверка должна быть ДАЛЬШЕ московской"
|
||
assert trade_in_module._resolve_coverage_city(south_lat, ekb_lon) == ("Екатеринбург", 8, True)
|
||
|
||
|
||
def test_sverdlovsk_resolution_identical_to_one_global_radius() -> None:
|
||
"""Свердловская область байт-в-байт: 25 км ей никто не менял.
|
||
|
||
Сравнение поведения, а не констант: густая решётка по всей области плюс
|
||
кольца по 24.5 и 25.5 км вокруг каждого свердловского центра (там, где
|
||
граница радиуса и проходит) — ответ нового резолвера обязан совпасть
|
||
с ответом прежнего, у которого радиус был один глобальный.
|
||
"""
|
||
import math
|
||
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
|
||
points: list[tuple[float, float]] = []
|
||
lat = 55.0
|
||
while lat <= 60.5:
|
||
lon = 57.0
|
||
while lon <= 66.0:
|
||
points.append((round(lat, 4), round(lon, 4)))
|
||
lon += 0.25
|
||
lat += 0.25
|
||
|
||
moscow_keys = {
|
||
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
||
}
|
||
sverdlovsk = {
|
||
k: v
|
||
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
||
if trade_in_module._fold_city(k) not in moscow_keys
|
||
}
|
||
assert len(sverdlovsk) == 9
|
||
for clat, clon in sverdlovsk.values():
|
||
for km in (24.5, 25.5):
|
||
dlat = km / 111.19
|
||
dlon = km / (111.19 * math.cos(math.radians(clat)))
|
||
points += [
|
||
(clat + dlat, clon),
|
||
(clat - dlat, clon),
|
||
(clat, clon + dlon),
|
||
(clat, clon - dlon),
|
||
]
|
||
|
||
mismatched = [
|
||
(lat, lon)
|
||
for lat, lon in points
|
||
if trade_in_module._resolve_coverage_city(lat, lon) != _legacy_resolve(lat, lon)
|
||
]
|
||
assert mismatched == [], f"{len(mismatched)} точек Свердловской изменили резолв"
|
||
assert len(points) > 800
|
||
|
||
|
||
def test_phantom_exclave_no_longer_gives_moscow_to_pushkino(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой».
|
||
|
||
Возвращаем призрак в таблицу — Пушкино снова резолвится в «Москву»: это
|
||
доказывает, что дефект чинит именно выброс точки, а не что-нибудь ещё.
|
||
"""
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
|
||
pushkino = _MOSCOW_OBLAST_CONTROLS["Пушкино"]
|
||
assert trade_in_module._resolve_coverage_city(*_PHANTOM_EXCLAVE) != ("Москва", 12, True)
|
||
assert trade_in_module._resolve_coverage_city(*pushkino) == ("", 0, False)
|
||
|
||
ghost_key = "Москва (Восточный, эксклав)"
|
||
monkeypatch.setattr(
|
||
trade_in_module,
|
||
"_CITY_CENTROIDS_DEG",
|
||
{**trade_in_module._CITY_CENTROIDS_DEG, ghost_key: _PHANTOM_EXCLAVE},
|
||
)
|
||
# Возвращаем призрак целиком, как он жил: координаты, порог «Москвы»
|
||
# и московский радиус — иначе проверялась бы не сетка, а таблица порогов.
|
||
monkeypatch.setattr(
|
||
trade_in_module,
|
||
"_COVERAGE_CITY_THRESHOLDS",
|
||
{
|
||
**trade_in_module._COVERAGE_CITY_THRESHOLDS,
|
||
trade_in_module._fold_city(ghost_key): (
|
||
trade_in_module.COVERAGE_MOSCOW_DISPLAY,
|
||
trade_in_module.COVERAGE_MOSCOW_MIN_N,
|
||
),
|
||
},
|
||
)
|
||
monkeypatch.setattr(
|
||
trade_in_module,
|
||
"_CENTROID_RADIUS_KM",
|
||
{
|
||
**trade_in_module._CENTROID_RADIUS_KM,
|
||
trade_in_module._fold_city(ghost_key): trade_in_module.COVERAGE_MOSCOW_MATCH_RADIUS_KM,
|
||
},
|
||
)
|
||
assert trade_in_module._resolve_coverage_city(*pushkino) == ("Москва", 12, True)
|
||
|
||
|
||
def test_added_grid_points_return_districts_lost_to_negative_centroids(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Каждая добавленная точка что-то возвращает: без неё район теряется.
|
||
|
||
Для всех 12 точек: в самой точке резолвер отдаёт «Москву», а если убрать
|
||
её из таблицы — «город не определён» (район уходит подмосковному центру).
|
||
Мёртвых строк в добавке нет.
|
||
"""
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
|
||
full = trade_in_module._CITY_CENTROIDS_DEG
|
||
for key in _ADDED_GRID_KEYS:
|
||
lat, lon = full[key]
|
||
assert trade_in_module._resolve_coverage_city(lat, lon) == ("Москва", 12, True), key
|
||
monkeypatch.setattr(
|
||
trade_in_module,
|
||
"_CITY_CENTROIDS_DEG",
|
||
{k: v for k, v in full.items() if k != key},
|
||
)
|
||
assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False), (
|
||
f"{key} ничего не возвращает — строка мёртвая"
|
||
)
|
||
monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", full)
|
||
|
||
|
||
def test_negative_centroid_radius_is_unbounded_and_endpoint_survives_it(
|
||
trade_in_app: FastAPI,
|
||
) -> None:
|
||
"""У отрицательной точки радиус бесконечный, и ручка на этом не падает.
|
||
|
||
Отрицательный центроид действует ровно там, где он ближайший, и на любом
|
||
расстоянии означает «город не определён»: точка в глухом углу области,
|
||
где ближайший центр — подмосковный и дальше 25 км, отдаёт not_covered
|
||
и 200, а не 500 по отсутствующему порогу.
|
||
"""
|
||
from app.api.v1 import trade_in as trade_in_module
|
||
|
||
far_lat, far_lon = 55.30, 38.75
|
||
nearest_km, nearest_name = min(
|
||
(trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name)
|
||
for name, (clat, clon) in (
|
||
*trade_in_module._CITY_CENTROIDS_DEG.items(),
|
||
*trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
|
||
)
|
||
)
|
||
assert nearest_name in trade_in_module._COVERAGE_NEGATIVE_CENTROIDS_DEG
|
||
assert nearest_km > trade_in_module.COVERAGE_CITY_MATCH_RADIUS_KM
|
||
assert trade_in_module._centroid_radius_km(nearest_name) == float("inf")
|
||
assert trade_in_module._resolve_coverage_city(far_lat, far_lon) == ("", 0, False)
|
||
|
||
db = _db_mock_returning(_row(40, 30.0))
|
||
_override(trade_in_app, db)
|
||
resp = TestClient(trade_in_app).post(
|
||
"/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": far_lat, "lon": far_lon}
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
assert resp.json()["status"] == "not_covered"
|
||
assert resp.json()["city"] == ""
|
||
|
||
|
||
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 ─────────────────
|
||
|
||
|
||
def test_median_age_null_below_min_age_samples(trade_in_app: FastAPI) -> None:
|
||
"""n_with_age=2 (< COVERAGE_MIN_AGE_SAMPLES=5) → median_listing_age_days null,
|
||
даже если SQL посчитал percentile — "медиана" по 1-2 объявлениям не медиана."""
|
||
from app.api.v1.trade_in import COVERAGE_MIN_AGE_SAMPLES
|
||
|
||
assert COVERAGE_MIN_AGE_SAMPLES == 5
|
||
db = _db_mock_returning(_row(20, 40.0, n_with_age=2))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
data = resp.json()
|
||
assert data["n_listings"] == 20 # когорта покрытия не урезается возрастным фильтром
|
||
assert data["n_with_age"] == 2
|
||
assert data["median_listing_age_days"] is None
|
||
|
||
|
||
def test_median_age_present_at_min_age_samples_threshold(trade_in_app: FastAPI) -> None:
|
||
"""n_with_age=5 (== порог) → median_listing_age_days отдаётся."""
|
||
db = _db_mock_returning(_row(20, 40.0, n_with_age=5))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
data = resp.json()
|
||
assert data["n_with_age"] == 5
|
||
assert data["median_listing_age_days"] == 40
|
||
|
||
|
||
def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None:
|
||
"""COVERAGE_MAX_AGE_DAYS=365 передаётся в SQL как параметр — выбросы (мёртвые
|
||
объявления) отсекаются 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
|
||
|
||
assert COVERAGE_MAX_AGE_DAYS == 365
|
||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
|
||
call_args = db.execute.call_args
|
||
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
|
||
assert params["max_age_days"] == 365
|
||
|
||
sql_text = str(call_args[0][0])
|
||
# 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 ────────────────────────────────────────
|
||
|
||
|
||
def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None:
|
||
"""SQL params: radius=1000 (строго), area ±15%, rooms exact."""
|
||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
|
||
assert db.execute.called
|
||
call_args = db.execute.call_args
|
||
params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {})
|
||
assert params["radius"] == 1000
|
||
assert params["rooms"] == 2
|
||
assert params["area_min"] == pytest.approx(50.0 * 0.85)
|
||
assert params["area_max"] == pytest.approx(50.0 * 1.15)
|
||
assert params["fresh_days"] == 14
|
||
|
||
|
||
# ── MAJOR-1: cohort predicates — sync с estimator._COMMON_WHERE / Tier W ────────
|
||
#
|
||
# Прямая регрессия из независимого ревью #2894: без этих трёх предикатов проба
|
||
# отвечает "ok" в точках, где платный эстиматор (radius Tier W, тот же 1000м)
|
||
# реально видит 0 — потому что вся когорта состоит из новостроек / city-centroid
|
||
# листингов, которые estimator._COMMON_WHERE / Tier W уже отсекают. Тест ловит
|
||
# случайное удаление ЛЮБОГО из трёх предикатов на уровне сгенерированного SQL —
|
||
# без живой БД, как и остальные тесты этого файла (см. test_gar_flats_loader.py
|
||
# для опционального real-Postgres-варианта аналогичной проверки в этом репо).
|
||
|
||
|
||
def test_cohort_sql_excludes_novostroyki(trade_in_app: FastAPI) -> None:
|
||
"""Guard новостроек — sync с estimator._COMMON_WHERE (5460) / Tier W (5932)."""
|
||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
|
||
sql_text = str(db.execute.call_args[0][0])
|
||
assert "listing_segment IS NULL OR listing_segment = 'vtorichka'" in sql_text
|
||
|
||
|
||
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)."""
|
||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
|
||
sql_text = str(db.execute.call_args[0][0])
|
||
assert "geo_precision IS DISTINCT FROM 'city'" in sql_text
|
||
|
||
|
||
def test_cohort_sql_excludes_zero_price(trade_in_app: FastAPI) -> None:
|
||
"""price_rub > 0 — sync с estimator._COMMON_WHERE (5441) / Tier W (5916)."""
|
||
db = _db_mock_returning(_row(0, None, n_with_age=0))
|
||
_override(trade_in_app, db)
|
||
|
||
client = TestClient(trade_in_app)
|
||
client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD)
|
||
|
||
sql_text = str(db.execute.call_args[0][0])
|
||
assert "price_rub > 0" in sql_text
|
||
|
||
|
||
# ── Live-DB tests (self-skip без реальной Postgres+PostGIS) ────────────────────
|
||
#
|
||
# Опциональные тесты против настоящего Postgres (тот же паттерн self-skip, что
|
||
# test_gar_flats_loader.py::_live_session) — требуют TEST_DATABASE_URL/
|
||
# DATABASE_URL, указывающий на реальную БД (не дефолтный localhost:5432/test-
|
||
# заглушку); иначе skip. В CI (ci-tradein.yml) этот DSN всегда живой Postgres+
|
||
# PostGIS-контейнер.
|
||
#
|
||
# Fix (повторная проверка #2894): раньше `_live_session()` вызывался И в
|
||
# `pytest.mark.skipif(...)` (на этапе СБОРА тестов — соединение открывалось и
|
||
# никогда не закрывалось, при реальном DSN это утечка на КАЖДЫЙ импорт файла),
|
||
# И повторно внутри тела единственного live-теста. Теперь доступность БД
|
||
# проверяется отдельной дешёвой функцией с явным закрытием соединения
|
||
# (`_live_db_available`), а сама Session выдаётся pytest-фикстурой
|
||
# (`live_session`) с гарантированным close() в finally, а не ручным вызовом.
|
||
|
||
|
||
def _live_db_available() -> bool:
|
||
"""Дешёвая проверка доступности live-Postgres — соединение открывается и
|
||
СРАЗУ закрывается (`with engine.connect()`), никакого висящего ORM Session.
|
||
|
||
Используется только в `pytest.mark.skipif(...)`, который вычисляется на
|
||
этапе сбора тестов — до фикстур.
|
||
"""
|
||
try:
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy import text as sa_text
|
||
|
||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||
if not dsn or "localhost:5432/test" in dsn:
|
||
return False
|
||
engine = create_engine(dsn, future=True)
|
||
try:
|
||
with engine.connect() as conn:
|
||
conn.execute(sa_text("SELECT 1"))
|
||
return True
|
||
finally:
|
||
engine.dispose()
|
||
except Exception:
|
||
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) — изолируют
|
||
# тестовую когорту от прод-данных без нужды в COMMIT/rollback гимнастики поверх
|
||
# чужой транзакции.
|
||
_LIVE_LAT, _LIVE_LON = 1.111, 2.222
|
||
|
||
|
||
@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД")
|
||
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 app.api.v1.trade_in import coverage_probe
|
||
from app.schemas.trade_in import CoverageProbeInput
|
||
|
||
db = live_session
|
||
rows = [
|
||
# (source_url suffix, listing_segment, geo_precision, price_rub) — все
|
||
# остальные поля общие: rooms=2, area_m2=50, is_active, scraped_at=NOW().
|
||
("ok-vtorichka", None, None, 5_000_000), # counted
|
||
("bad-novostroyka", "novostroyki", None, 5_000_000), # 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}"
|
||
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,
|
||
listing_segment, geo_precision)
|
||
VALUES
|
||
('test', :url, :url, :url, 'test addr', :lat, :lon,
|
||
2, 50.0, :price, true, NOW(), :segment, :geo_precision)
|
||
"""
|
||
),
|
||
{
|
||
"url": url,
|
||
"lat": _LIVE_LAT,
|
||
"lon": _LIVE_LON,
|
||
"price": price,
|
||
"segment": segment,
|
||
"geo_precision": geo_precision,
|
||
},
|
||
)
|
||
|
||
result = coverage_probe(
|
||
CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db
|
||
)
|
||
# Только первая (ok-vtorichka) строка должна попадать в когорту —
|
||
# каждая следующая вставка не должна сдвигать счётчик.
|
||
assert result.n_listings == 1, (
|
||
f"predicate regression: n_listings={result.n_listings} after inserting "
|
||
f"{suffix!r} (segment={segment!r} geo_precision={geo_precision!r} "
|
||
f"price={price}) — expected still 1 (only ok-vtorichka counted)"
|
||
)
|
||
|
||
|
||
@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»)"
|
||
)
|