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
This commit is contained in:
parent
b1727ca39c
commit
50c1df5e0e
2 changed files with 1090 additions and 5 deletions
|
|
@ -2776,15 +2776,206 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
|
|||
COVERAGE_GREEN_MIN_N = 8
|
||||
COVERAGE_YELLOW_MIN_N = 12
|
||||
|
||||
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
|
||||
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
|
||||
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
|
||||
# тестом test_offered_cities_match_coverage_cities), а Москвы в том дропдауне
|
||||
# нет и быть не должно — там выбирают город Свердловской области. Порог при
|
||||
# этом нужен, и живёт он здесь, в том же _COVERAGE_CITY_THRESHOLDS.
|
||||
#
|
||||
# Тир — ЖЁЛТЫЙ (12), не зелёный. Замер на проде 10.09.2026: 200 случайных
|
||||
# московских объявлений, когорта считалась тем же предикатом, что сама проба
|
||||
# (см. SQL в coverage_probe ниже) — медиана когорты 14, p25 = 8, доля точек с
|
||||
# когортой >= 12 равна 0.57, с когортой >= 8 равна 0.77. Контроли той же
|
||||
# метрикой: Екатеринбург (зелёный, порог 8) — медиана 37, доля >= 12 равна
|
||||
# 0.865; Нижний Тагил (жёлтый, порог 12) — медиана 11, доля >= 12 равна 0.473.
|
||||
# То есть по плотности когорты Москва втрое реже зелёного эталона и стоит
|
||||
# вплотную к жёлтому: с порогом 8 «ok» получали бы 77% адресов, но за этим «ok»
|
||||
# стояла бы заметно менее надёжная оценка. Плюс модельная сторона для Москвы
|
||||
# ещё не откалибрована (время правится свердловским рядом СберИндекса, полоса
|
||||
# цен одна на весь город), так что 43% адресов честнее показать как thin.
|
||||
COVERAGE_MOSCOW_MIN_N = COVERAGE_YELLOW_MIN_N
|
||||
|
||||
# Ключ центроида и display-имя города — РАЗНЫЕ вещи, и здесь это видно яснее
|
||||
# всего: у Москвы 67 центроидов (сетка, см. `_MOSCOW_GRID_DEG` ниже), а наружу,
|
||||
# в поле `city` ответа, обязано уходить одно имя «Москва» и один порог. Иначе
|
||||
# повторится ровно тот баг, что был с Берёзовским: человек получает в ответе
|
||||
# название района вместо своего города.
|
||||
COVERAGE_MOSCOW_DISPLAY = "Москва"
|
||||
|
||||
# СЕТКА МОСКОВСКИХ ЦЕНТРОИДОВ (11.09.2026).
|
||||
#
|
||||
# Откуда координаты. Это не справочник районов и не ручной подбор: точки
|
||||
# получены кластеризацией НАШЕГО ЖЕ корпуса объявлений средствами PostGIS —
|
||||
# `ST_ClusterKMeans(ST_Transform(geom, 32637), K) OVER ()` по 35 552 активным
|
||||
# вторичным объявлениям region_code=77 (geom заполнена у 100%), центроид
|
||||
# кластера = `ST_Centroid(ST_Collect(...))` в UTM 37N, округление до 4 знаков.
|
||||
# Поэтому точки стоят там, где реально живут объявления, а не там, где на карте
|
||||
# нарисован центр района. Подписи в ключах — человеческие ярлыки для читаемости
|
||||
# (ближайший район/поселение), на резолв они не влияют никак.
|
||||
#
|
||||
# Почему точек 67. Кластеризация дала K=56: порог качества был «меньше 2%
|
||||
# московских объявлений уходит к подмосковному центроиду», свип по K на тех же
|
||||
# данных — 24 → 6.06%, 30 → 4.02%, 36 → 3.67%, 42 → 3.46%, 48 → 2.23%,
|
||||
# 54 → 2.12%, 56 → 1.79%, 60 → 1.64%, 75 → 0.84%, 90 → 0.45%, и K=56 — первое
|
||||
# значение, берущее 2%. Из этих 56 один кластер выброшен как артефакт геокода,
|
||||
# и 12 точек добавлены вручную по замеру 11.09.2026 (жилые районы, целиком
|
||||
# проигрывавшие конкурс подмосковной точке): 56 − 1 + 12 = 67.
|
||||
#
|
||||
# Выброшенный призрак. «Москва (Восточный, эксклав)» 56.0087/37.7960: n=1,
|
||||
# отрыв от остальной сетки 17.9 км. Адрес объявления — «ВАО, р-н Восточный, 4»,
|
||||
# а настоящий район Восточный лежит на 55.81/37.85: геокодер промахнулся
|
||||
# на 22 км. Точка стояла в 3.06 км от Пушкино и раздавала ему имя «Москва».
|
||||
# Взамен добавлена «Москва (Восточный)» по фактическому центру девяти
|
||||
# объявлений района. Проверены ВСЕ кластеры с n<60 либо изоляцией >12 км
|
||||
# (9 штук, адреса подняты с прода) — артефакт ровно один. Второй кандидат,
|
||||
# «Москва (юг ТАО)» 55.2643/37.1033 (n=1, адрес без улицы и дома =
|
||||
# settlement-fallback геокодера), ОСТАВЛЕН намеренно: точка стоит внутри ТАО,
|
||||
# при радиусе 8 км не достаёт ни до одного города области, а снос стоил бы
|
||||
# покрытия анклава.
|
||||
#
|
||||
# Где проходит граница с областью. Двумя механизмами сразу, и нужны оба.
|
||||
# (1) Конкурс ближайшего центроида: 32 подмосковные точки
|
||||
# (`_COVERAGE_NEGATIVE_CENTROIDS_DEG` ниже) участвуют в поиске ближайшего, но
|
||||
# порога не имеют, поэтому адрес, для которого выиграл подмосковный центр,
|
||||
# получает «город не определён». (2) Радиус, который теперь СВОЙСТВО ЦЕНТРОИДА
|
||||
# (`_CENTROID_RADIUS_KM` ниже), а не одна константа на всю страну: у московских
|
||||
# точек он 8 км, у свердловских остались прежние 25.
|
||||
#
|
||||
# Почему радиус пришлось сделать свойством точки. 25 км были рассчитаны на ОДИН
|
||||
# центроид в центре города — круг вокруг Кремля. У плотной сетки круги
|
||||
# СКЛАДЫВАЮТСЯ, и объединение 67 кругов по 25 км — это уже не «Москва с
|
||||
# запасом», а пятно, накрывающее половину области. Наро-Фоминск, Кубинка и Чехов
|
||||
# резолвились как «Москва» именно так: до ближайшей точки сетки им 9.83, 17.18
|
||||
# и 22.25 км (расстояния посчитаны _haversine_km этого же модуля, сфера
|
||||
# R=6371 — замер на эллипсоиде PostGIS даёт на 0.1-0.5 км больше), то есть
|
||||
# внутрь общего круга они попадали, а собственной
|
||||
# отрицательной точки у них нет. Прежнее утверждение в этом месте — будто
|
||||
# радиус 25 км «больше не ограничение, запас четырёхкратный» — было ложным:
|
||||
# ограничение не исчезло, оно поменяло знак. Раньше радиус резал покрытие
|
||||
# изнутри (адрес дальше 25 км от Кремля терял город), теперь протекал наружу.
|
||||
#
|
||||
# Откуда 8 км. Свип по радиусу на итоговой сетке, тот же корпус (35 552
|
||||
# активных вторичных объявления, region_code=77): R = 5, 6, 7 и 8 дают
|
||||
# ОДИНАКОВЫЙ результат — 0.00% объявлений вне радиуса, 29/29 московских
|
||||
# контрольных адресов резолвятся в «Москву», 32/32 областных дают «не
|
||||
# определён». Верхний край плато жёсткий: при R=10 внутрь входит Наро-Фоминск
|
||||
# (9.83 км — город БЕЗ отрицательной точки), при R=12 — ещё Голицыно и
|
||||
# Лыткарино. Берём верхний край: 8 км — это 3.5 км запаса на адреса вне
|
||||
# корпуса (худший московский контроль — Рублёво, 4.44 км до сетки) и всё ещё
|
||||
# на 1.83 км ниже потолка 9.83. Брать меньше нечем оправдать: по корпусу
|
||||
# выигрыша нет, а запас на дырки в сетке тает.
|
||||
#
|
||||
# Остаточная цена. 48 московских объявлений из 35 552 = 0.14% (на прежней
|
||||
# конфигурации — 636 = 1.79%) всё ещё выигрываются подмосковным центром:
|
||||
# Молжаниновский 15, Реутов 8, Подольск 7, Долгопрудный 7, Мытищи 7,
|
||||
# Красногорск 3, Королёв 1. Крупнейший кусок снимается 13-й точкой
|
||||
# «Молжаниновский» 55.9480/37.3478 (0.14% → 0.09%) — она в замере посчитана,
|
||||
# но в список не внесена, чтобы конфигурация здесь совпадала с той, на которой
|
||||
# прогнаны оба контрольных набора. Одно объявление не берётся ничем:
|
||||
# 56.0087/37.7960, тот самый кривой геокод, — лечится перегеокодированием
|
||||
# строки адреса, а не сеткой.
|
||||
#
|
||||
# Не перегенерируйте кластеризацию перед правкой: ST_ClusterKMeans без сида
|
||||
# недетерминирован, разброс между прогонами ±0.2 п.п. Список ниже перепроверен
|
||||
# отдельным прогоном именно в том виде, в каком он здесь записан.
|
||||
_MOSCOW_GRID_DEG: dict[str, tuple[float, float]] = {
|
||||
"Москва (Рогово, ТАО)": (55.2146, 37.0695),
|
||||
"Москва (юг ТАО)": (55.2643, 37.1033),
|
||||
"Москва (Вороновское, ТАО)": (55.3159, 37.1759),
|
||||
"Москва (Кленовское, ТАО)": (55.3343, 37.3299),
|
||||
"Москва (ТАО у Подольска)": (55.3569, 37.4010),
|
||||
"Москва (Шишкин Лес, ТАО)": (55.4202, 37.1687),
|
||||
"Москва (Щапово, ТАО)": (55.4212, 37.3979),
|
||||
"Москва (Новофёдоровское, ТАО)": (55.4307, 36.8675),
|
||||
"Москва (Троицк, юг)": (55.4568, 37.2867),
|
||||
"Москва (Остафьево, НАО)": (55.4760, 37.5249),
|
||||
"Москва (Киевский, ТАО)": (55.4884, 36.9199),
|
||||
"Москва (Троицк)": (55.4953, 37.3150),
|
||||
"Москва (Щербинка, НАО)": (55.5046, 37.5578),
|
||||
"Москва (Ватутинки, НАО)": (55.5187, 37.3605),
|
||||
"Москва (Первомайское, ТАО)": (55.5405, 37.1599),
|
||||
"Москва (Коммунарка, НАО)": (55.5474, 37.4927),
|
||||
"Москва (Филимонковское, НАО)": (55.5568, 37.3188),
|
||||
"Москва (Северное Бутово)": (55.5615, 37.5697),
|
||||
"Москва (Саларьево, НАО)": (55.5862, 37.4568),
|
||||
"Москва (Марушкино, НАО)": (55.5933, 37.1658),
|
||||
"Москва (Бирюлёво Западное)": (55.5945, 37.6447),
|
||||
"Москва (Внуковское, НАО)": (55.6030, 37.3796),
|
||||
"Москва (Ясенево)": (55.6138, 37.5535),
|
||||
"Москва (Зябликово)": (55.6225, 37.7303),
|
||||
"Москва (Ново-Переделкино)": (55.6317, 37.3215),
|
||||
"Москва (Нагорный)": (55.6425, 37.6245),
|
||||
"Москва (Солнцево)": (55.6455, 37.3966),
|
||||
"Москва (Тропарёво-Никулино)": (55.6576, 37.4684),
|
||||
"Москва (Люблино)": (55.6696, 37.7461),
|
||||
"Москва (Обручевский)": (55.6711, 37.5432),
|
||||
"Москва (Даниловский)": (55.6921, 37.6397),
|
||||
"Москва (Некрасовка)": (55.7033, 37.9093),
|
||||
"Москва (Раменки)": (55.7048, 37.4870),
|
||||
"Москва (Кузьминки)": (55.7143, 37.7723),
|
||||
"Москва (Кунцево)": (55.7281, 37.4239),
|
||||
"Москва (Якиманка)": (55.7290, 37.6055),
|
||||
"Москва (Таганский)": (55.7370, 37.6845),
|
||||
"Москва (Пресненский)": (55.7486, 37.5282),
|
||||
"Москва (Новогиреево)": (55.7545, 37.8107),
|
||||
"Москва (Тверской)": (55.7655, 37.6038),
|
||||
"Москва (Хорошёво-Мнёвники)": (55.7657, 37.4668),
|
||||
"Москва (Преображенское)": (55.7886, 37.7045),
|
||||
"Москва (Хорошёвский)": (55.7951, 37.5373),
|
||||
"Москва (Строгино)": (55.7982, 37.3859),
|
||||
"Москва (Северное Измайлово)": (55.8071, 37.7881),
|
||||
"Москва (Останкинский)": (55.8086, 37.6174),
|
||||
"Москва (Покровское-Стрешнево)": (55.8203, 37.4470),
|
||||
"Москва (Митино)": (55.8467, 37.3877),
|
||||
"Москва (Отрадное)": (55.8558, 37.5796),
|
||||
"Москва (Ховрино)": (55.8581, 37.4924),
|
||||
"Москва (Бабушкинский)": (55.8633, 37.6740),
|
||||
"Москва (Лианозово)": (55.8944, 37.5622),
|
||||
"Москва (Куркино — Молжаниново)": (55.9169, 37.3837),
|
||||
"Москва (Зеленоград, юг)": (55.9793, 37.1620),
|
||||
"Москва (Зеленоград, север)": (55.9958, 37.2147),
|
||||
# Кластер «Москва (Восточный, эксклав)» (56.0087, 37.7960) ВЫБРОШЕН
|
||||
# 11.09.2026 как артефакт геокода — см. «Выброшенный призрак» выше.
|
||||
#
|
||||
# Ниже — 12 точек, добавленных тем же замером вручную: жилые районы
|
||||
# Москвы, которые целиком проигрывали конкурс ближайшего подмосковной
|
||||
# точке. В комментарии — сколько объявлений возвращает точка и кому они
|
||||
# уходили (прод-корпус, is_active AND region_code=77).
|
||||
"Москва (Восточный)": (55.8194, 37.8740), # +9, уходили к Балашихе
|
||||
"Москва (Левобережный)": (55.8713, 37.4592), # +91, к Химкам
|
||||
"Москва (Орехово-Борисово Южное)": (55.5986, 37.7233), # +80, к Развилке
|
||||
"Москва (Северный)": (55.9278, 37.5411), # +79, к Долгопрудному
|
||||
"Москва (Митино-запад)": (55.8410, 37.3507), # +78, к Красногорску
|
||||
"Москва (Ивановское)": (55.7717, 37.8326), # +65, к Реутову
|
||||
"Москва (Жулебино)": (55.6885, 37.8521), # +62, к Люберцам
|
||||
"Москва (Можайский-запад)": (55.7008, 37.3936), # +50, к Немчиновке
|
||||
"Москва (Новокосино)": (55.7385, 37.8576), # +44, к Реутову
|
||||
"Москва (Капотня)": (55.6341, 37.7981), # +29, к Дзержинскому
|
||||
"Москва (Куркино)": (55.8895, 37.3980), # +20, к Химкам
|
||||
"Москва (Некрасовка-восток)": (55.6838, 37.9216), # +13, к Люберцам
|
||||
}
|
||||
|
||||
# Все ключи сетки — один display «Москва» и один порог. Кортеж выводится из
|
||||
# самого словаря, чтобы список ключей физически не мог разойтись с сеткой.
|
||||
COVERAGE_MOSCOW_CENTROID_KEYS = tuple(_MOSCOW_GRID_DEG)
|
||||
|
||||
|
||||
def _fold_city(name: str) -> str:
|
||||
"""ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774)."""
|
||||
return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold()
|
||||
|
||||
|
||||
# Ключ — folded ИМЯ ЦЕНТРОИДА, значение — (display-имя города, порог). Для
|
||||
# свердловских городов ключ и display совпадают, для московских центроидов —
|
||||
# нет (все 67 ключей сетки дают «Москва»).
|
||||
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
||||
**{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES},
|
||||
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
|
||||
**{
|
||||
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
|
||||
for k in COVERAGE_MOSCOW_CENTROID_KEYS
|
||||
},
|
||||
}
|
||||
|
||||
# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой
|
||||
|
|
@ -2807,7 +2998,15 @@ _COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
|||
# статичного справочника из 8 географических центров населённых пунктов —
|
||||
# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же,
|
||||
# рядом с порогами, которые они резолвят.
|
||||
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered)
|
||||
# Радиусы матчинга. Радиус — СВОЙСТВО ЦЕНТРОИДА (таблица `_CENTROID_RADIUS_KM`
|
||||
# ниже, применяется в `_resolve_coverage_city`), потому что 25 км осмысленны
|
||||
# ровно для конфигурации «один центроид на город», а у Москвы центроидов 67 и
|
||||
# их круги складываются — разбор в комментарии над `_MOSCOW_GRID_DEG`.
|
||||
# Свердловские точки радиуса не меняли: у каждой из них один центр на город,
|
||||
# складываться нечему, а замер, которым выбраны эти 25 км, к сетке отношения
|
||||
# не имеет.
|
||||
COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дефолт: один центроид на город, дальше — not_covered
|
||||
COVERAGE_MOSCOW_MATCH_RADIUS_KM = 8.0 # сетка из 67 точек; верхний край плато 5..8, потолок 9.83
|
||||
|
||||
_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||
"Екатеринбург": (56.8389, 60.6057),
|
||||
|
|
@ -2819,8 +3018,99 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
|||
"Первоуральск": (56.9083, 59.9483),
|
||||
"Ревда": (56.7986, 59.9298),
|
||||
"Серов": (59.6047, 60.5772),
|
||||
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
|
||||
# Как сетка получена, почему точек именно столько, где проходит граница
|
||||
# с областью и какова остаточная цена — см. большой комментарий над
|
||||
# `_MOSCOW_GRID_DEG`. Здесь только подстановка: координаты и ключи живут
|
||||
# в одном месте, чтобы их нельзя было рассинхронизировать.
|
||||
**_MOSCOW_GRID_DEG,
|
||||
}
|
||||
|
||||
# ОТРИЦАТЕЛЬНЫЕ центроиды: подмосковные города и посёлки, попадающие внутрь
|
||||
# радиуса московских точек. Список 11.09.2026 перепроверен на итоговой
|
||||
# конфигурации (67 точек сетки, радиус 8 км): все 32 точки живы — ни одну
|
||||
# не перехватывает московский центроид, в самой точке выигрывает она сама,
|
||||
# и ответ остаётся «город не определён». Внутрь 8 км от какой-нибудь точки
|
||||
# сетки попадают 13 из них (Химки, Реутов, Люберцы, Котельники, Дзержинский,
|
||||
# Красногорск, Долгопрудный, Мытищи, Видное, Одинцово, Подольск, Апрелевка,
|
||||
# Селятино) — каждая выигрывает собственным центром. Ближайшая пара после
|
||||
# добавления новых точек: «Митино-запад» в 1.6 км от Красногорска. Участвуют
|
||||
# в конкурсе ближайшего центроида наравне с городами из
|
||||
# `_CITY_CENTROIDS_DEG`, но НАМЕРЕННО отсутствуют в `_COVERAGE_CITY_THRESHOLDS`:
|
||||
# центроид без порога = «город не определён», тот же ответ, что для точки
|
||||
# в чистом поле. Обоснование и цена — в комментарии «ГРАНИЦА С ОБЛАСТЬЮ» выше.
|
||||
#
|
||||
# Координаты — WGS84, общедоступные центры НП; список закрытый и расширяется
|
||||
# только по замеру (точка вне радиуса всех московских бесполезна, точка внутри
|
||||
# Москвы сожгла бы ещё часть покрытия). Ключи здесь и в `_CITY_CENTROIDS_DEG`
|
||||
# не должны пересекаться — это проверяется тестом.
|
||||
#
|
||||
# Города области, которые НЕ попадают ни в один московский радиус, здесь и не
|
||||
# нужны: Наро-Фоминск (9.83 км до сетки), Кубинка (17.18), Чехов (22.25) отдают
|
||||
# «город не определён» просто потому, что вне 8 км. При прежних 25 км все трое
|
||||
# резолвились как «Москва» — это и был дефект, ради которого радиус переехал
|
||||
# в свойство центроида.
|
||||
_COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||
"Андреевка": (55.9772, 37.1100),
|
||||
"Менделеево": (56.0333, 37.2333),
|
||||
"Сходня": (55.9500, 37.3000),
|
||||
"Чёрная Грязь": (55.9667, 37.3167),
|
||||
"Лунёво": (56.0000, 37.3500),
|
||||
"Поварово": (56.0667, 37.0667),
|
||||
"Дедовск": (55.8672, 37.1200),
|
||||
"Нахабино": (55.8500, 37.1833),
|
||||
"Реутов": (55.7614, 37.8564),
|
||||
"Подольск": (55.4312, 37.5450),
|
||||
"Апрелевка": (55.5500, 37.0700),
|
||||
"Немчиновка": (55.7050, 37.3450),
|
||||
"Химки": (55.8894, 37.4450),
|
||||
"Одинцово": (55.6789, 37.2639),
|
||||
"Лобня": (56.0100, 37.4750),
|
||||
"Мытищи": (55.9116, 37.7308),
|
||||
"Котельники": (55.6553, 37.8619),
|
||||
"Красногорск": (55.8317, 37.3300),
|
||||
"Люберцы": (55.6767, 37.8931),
|
||||
"Дзержинский": (55.6294, 37.8500),
|
||||
"Развилка": (55.5842, 37.7392),
|
||||
"Климовск": (55.3667, 37.5333),
|
||||
"Балашиха": (55.7969, 37.9386),
|
||||
"Истра": (55.9167, 36.8667),
|
||||
"Долгопрудный": (55.9386, 37.5100),
|
||||
"Королёв": (55.9142, 37.8256),
|
||||
"Видное": (55.5519, 37.7133),
|
||||
"Селятино": (55.5081, 36.9825),
|
||||
"Томилино": (55.6528, 37.9472),
|
||||
"Некрасовский": (56.0500, 37.5500),
|
||||
"Барвиха": (55.7333, 37.2333),
|
||||
"Железнодорожный": (55.7444, 38.0128),
|
||||
}
|
||||
|
||||
|
||||
# Радиус матчинга по центроиду: ключ — folded имя центроида, значение — км.
|
||||
# Чего здесь нет — берёт `COVERAGE_CITY_MATCH_RADIUS_KM` (25 км), то есть все
|
||||
# свердловские точки. Таблица выводится из самих словарей координат, чтобы
|
||||
# радиус физически не мог разойтись со списком точек.
|
||||
#
|
||||
# Почему у отрицательных точек радиус бесконечный. Отрицательный центроид
|
||||
# ничего не «накрывает»: он влияет на ответ только там, где он ближе всех
|
||||
# (в своей ячейке Вороного), а выигравший центроид без порога даёт «город не
|
||||
# определён» на ЛЮБОМ расстоянии — обе ветки `_resolve_coverage_city` (вышли
|
||||
# за радиус / нет порога) возвращают один и тот же ответ. Поэтому конечное
|
||||
# число здесь было бы декорацией: поведение оно не меняет, но читалось бы как
|
||||
# «дальше отрицательная точка перестаёт действовать», чего не происходит.
|
||||
# math.inf говорит правду — границу отрицательной точки задаёт геометрия
|
||||
# соседей, а не радиус, и попытка «подрезать» её числом ничего не вернёт
|
||||
# Москве, зато спрячет этот факт от следующего читателя.
|
||||
_CENTROID_RADIUS_KM: dict[str, float] = {
|
||||
**{_fold_city(k): COVERAGE_MOSCOW_MATCH_RADIUS_KM for k in COVERAGE_MOSCOW_CENTROID_KEYS},
|
||||
**{_fold_city(k): math.inf for k in _COVERAGE_NEGATIVE_CENTROIDS_DEG},
|
||||
}
|
||||
|
||||
|
||||
def _centroid_radius_km(centroid_key: str) -> float:
|
||||
"""Радиус матчинга КОНКРЕТНОГО центроида в км (см. `_CENTROID_RADIUS_KM`)."""
|
||||
return _CENTROID_RADIUS_KM.get(_fold_city(centroid_key), COVERAGE_CITY_MATCH_RADIUS_KM)
|
||||
|
||||
|
||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Расстояние по большому кругу (км), радиус Земли 6371 км."""
|
||||
|
|
@ -2836,23 +3126,49 @@ def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]:
|
|||
"""Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ.
|
||||
|
||||
Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него
|
||||
< `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно
|
||||
в пределах СОБСТВЕННОГО радиуса этого центроида (`_centroid_radius_km`:
|
||||
25 км у свердловских городов, 8 км у точек московской сетки — почему так,
|
||||
см. комментарий над `_MOSCOW_GRID_DEG`); иначе город не определён.
|
||||
Детерминированно
|
||||
и без участия клиента — см. комментарий над `_CITY_CENTROIDS_DEG` про то,
|
||||
почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда
|
||||
больше НЕ допускаются в качестве источника истины.
|
||||
|
||||
В конкурсе ближайшего участвуют И отрицательные центроиды
|
||||
(`_COVERAGE_NEGATIVE_CENTROIDS_DEG`) — подмосковные точки без порога.
|
||||
Выигравший центроид без порога означает «город не определён»: так граница
|
||||
Москвы и области проходит по конкурсу центров, а не по кругу радиуса
|
||||
(см. «ГРАНИЦА С ОБЛАСТЬЮ» над `_CITY_CENTROIDS_DEG`).
|
||||
|
||||
Возвращается display-имя из `_COVERAGE_CITY_THRESHOLDS`, а НЕ ключ
|
||||
центроида: у Москвы 67 центроидов на один город, и наружу все они обязаны
|
||||
отдавать «Москва» с одним порогом (см. `COVERAGE_MOSCOW_CENTROID_KEYS`).
|
||||
"""
|
||||
nearest_city: str | None = None
|
||||
nearest_km = math.inf
|
||||
for city, (clat, clon) in _CITY_CENTROIDS_DEG.items():
|
||||
for city, (clat, clon) in (
|
||||
*_CITY_CENTROIDS_DEG.items(),
|
||||
*_COVERAGE_NEGATIVE_CENTROIDS_DEG.items(),
|
||||
):
|
||||
distance_km = _haversine_km(lat, lon, clat, clon)
|
||||
if distance_km < nearest_km:
|
||||
nearest_km = distance_km
|
||||
nearest_city = city
|
||||
|
||||
if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM:
|
||||
# Радиус берётся у ПОБЕДИТЕЛЯ конкурса, а не общий: круги плотной сетки
|
||||
# складываются, и один глобальный радиус либо режет Свердловскую область,
|
||||
# либо протекает из Москвы в область (см. `_CENTROID_RADIUS_KM`).
|
||||
if nearest_city is None or nearest_km > _centroid_radius_km(nearest_city):
|
||||
return "", 0, False
|
||||
|
||||
display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)]
|
||||
# .get(), а не индексирование: ближайшим мог оказаться отрицательный
|
||||
# центроид (или новый центроид, для которого забыли завести порог) — это
|
||||
# «город не определён», а не KeyError и 500 на живом адресе.
|
||||
entry = _COVERAGE_CITY_THRESHOLDS.get(_fold_city(nearest_city))
|
||||
if entry is None:
|
||||
return "", 0, False
|
||||
|
||||
display, threshold = entry
|
||||
return display, threshold, True
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -304,6 +304,775 @@ def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None:
|
|||
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='Екатеринбург' обязана резолвиться в Берёзовский
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue