gendesign/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py
bot-backend 50c1df5e0e feat(msk): проба покрытия отвечает по Москве — сетка центроидов и радиус на точку
Резолвер города в пробе покрытия знал только 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
2026-09-11 01:37:27 +03:00

1434 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for 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»)"
)