feat(trade-in): 22 города Московской области в покрытии
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m34s
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m34s
CI Trade-In / backend-tests (pull_request) Successful in 5m7s
Тестировщику по области было нечего выбрать: реестр городов во фронте знал только Свердловскую область и Москву, а подмосковные населённые пункты лежали в _COVERAGE_NEGATIVE_CENTROIDS_DEG — намеренно, решением от 11.09.2026 «центроид без порога = город не определён». Решение пересмотрено на замере, а не на желании открыть регион. Метрика — та же, которой Москве выбирали жёлтый тир: 120 проб на город, радиус 8 км от центра, когорта = is_active + точные комнаты + площадь ±15% + scraped_at > now()-14d + ST_DWithin 1000 м. Результат: медиана когорты по городам области 17-51, доля проб с >=12 аналогов 0.63-0.92. У Москвы, которая уже жёлтая, медиана 14 и доля 0.57; у Нижнего Тагила (тоже жёлтый) — 11 и 0.473. То есть каждый город области плотнее Москвы, и жёлтый порог для них — консервативный выбор, а не натяжка. Парность реестра и порогов сохранена (гейт test_public_mera_api): 22 города добавлены и во фронт, и в _COVERAGE_CITY_THRESHOLDS с COVERAGE_YELLOW_MIN_N, и в _CITY_CENTROIDS_DEG. 12 центроидов перенесены из негативного списка (32 -> 20 записей, ключи двух словарей не пересекаются), 10 взяты из MOSCOW_OBLAST_CITY_ANCHORS — там они резолвлены Nominatim. MOSCOW_CITY больше не берётся по индексу последнего элемента: список перестал заканчиваться Москвой, и молчаливая поломка стоила бы дорого — теперь поиск по id с явным throw. Тесты сетки покрытия переписаны по факту, а не подогнаны: 12 промоутнутых координат раньше держали утверждение «здесь город не определён». Контрольные точки пересчитаны (Пушкино теперь резолвится в Королёв, контроль бесконечного радиуса переехал с точки в 22.9 км от Коломны).
This commit is contained in:
parent
1bd5283aa6
commit
f6dd426494
4 changed files with 231 additions and 68 deletions
|
|
@ -2844,6 +2844,46 @@ COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Ураль
|
||||||
COVERAGE_GREEN_MIN_N = 8
|
COVERAGE_GREEN_MIN_N = 8
|
||||||
COVERAGE_YELLOW_MIN_N = 12
|
COVERAGE_YELLOW_MIN_N = 12
|
||||||
|
|
||||||
|
# Московская область (регион 50) добавлена 2026-09-16 — все 22 города сразу в
|
||||||
|
# жёлтый тир, той же метрикой, которой выбирали тир Москве. Замер: 120 проб на
|
||||||
|
# город, радиус 8 км от центра, когорта = is_active + точные комнаты + площадь
|
||||||
|
# +-15% + scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты по
|
||||||
|
# городам области 17-51, доля проб с когортой >= 12 равна 0.63-0.92 (Видное
|
||||||
|
# 51/0.83, Балашиха 40/0.88, Железнодорожный 39/0.92, Люберцы 37/0.87,
|
||||||
|
# Электросталь 35/0.83, Химки 30/0.74, Домодедово 30/0.79, Ногинск 29/0.85,
|
||||||
|
# Реутов 28/0.82, Долгопрудный 27/0.72, Красногорск 27/0.77, Одинцово 27/0.78,
|
||||||
|
# Подольск 26/0.83, Сергиев Посад 25/0.79, Королёв 24/0.79, Мытищи 23/0.73,
|
||||||
|
# Орехово-Зуево 22/0.73, Серпухов 22/0.66, Раменское 21/0.68,
|
||||||
|
# Наро-Фоминск 20/0.73, Щёлково 19/0.73, Коломна 17/0.63). Контроль той же
|
||||||
|
# метрикой: Москва (уже жёлтая) — медиана 14, доля 0.57; Нижний Тагил (тоже
|
||||||
|
# жёлтый) — медиана 11, доля 0.473. То есть КАЖДЫЙ город области плотнее уже
|
||||||
|
# принятого жёлтого эталона (Москвы) — зелёный тир не рассматривался, порог
|
||||||
|
# 12 достаточен и единообразен. Порядок — по убыванию медианы когорты.
|
||||||
|
COVERAGE_MO_CITIES = (
|
||||||
|
"Видное",
|
||||||
|
"Балашиха",
|
||||||
|
"Железнодорожный",
|
||||||
|
"Люберцы",
|
||||||
|
"Электросталь",
|
||||||
|
"Химки",
|
||||||
|
"Домодедово",
|
||||||
|
"Ногинск",
|
||||||
|
"Реутов",
|
||||||
|
"Долгопрудный",
|
||||||
|
"Красногорск",
|
||||||
|
"Одинцово",
|
||||||
|
"Подольск",
|
||||||
|
"Сергиев Посад",
|
||||||
|
"Королёв",
|
||||||
|
"Мытищи",
|
||||||
|
"Орехово-Зуево",
|
||||||
|
"Серпухов",
|
||||||
|
"Раменское",
|
||||||
|
"Наро-Фоминск",
|
||||||
|
"Щёлково",
|
||||||
|
"Коломна",
|
||||||
|
)
|
||||||
|
|
||||||
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
|
# Москва добавлена 10.09.2026 — ОТДЕЛЬНОЙ константой, а не в
|
||||||
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
|
# COVERAGE_YELLOW_CITIES. Причина структурная: пара GREEN/YELLOW_CITIES выше —
|
||||||
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
|
# это контракт со свердловским дропдауном на сайте (city-registry.ts, сверяется
|
||||||
|
|
@ -3040,6 +3080,7 @@ def _fold_city(name: str) -> str:
|
||||||
_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = {
|
_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_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(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES},
|
||||||
|
**{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_MO_CITIES},
|
||||||
**{
|
**{
|
||||||
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
|
_fold_city(k): (COVERAGE_MOSCOW_DISPLAY, COVERAGE_MOSCOW_MIN_N)
|
||||||
for k in COVERAGE_MOSCOW_CENTROID_KEYS
|
for k in COVERAGE_MOSCOW_CENTROID_KEYS
|
||||||
|
|
@ -3086,6 +3127,36 @@ _CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||||
"Первоуральск": (56.9083, 59.9483),
|
"Первоуральск": (56.9083, 59.9483),
|
||||||
"Ревда": (56.7986, 59.9298),
|
"Ревда": (56.7986, 59.9298),
|
||||||
"Серов": (59.6047, 60.5772),
|
"Серов": (59.6047, 60.5772),
|
||||||
|
# Московская область (регион 50), 22 города — добавлены 2026-09-16 вместе с
|
||||||
|
# COVERAGE_MO_CITIES (см. обоснование порога там же). Координаты для 12 из
|
||||||
|
# 22 перенесены как есть из `_COVERAGE_NEGATIVE_CENTROIDS_DEG` (эти города
|
||||||
|
# раньше были ТОЛЬКО отрицательными точками московской сетки — конкурс
|
||||||
|
# ближайшего центроида без порога, теперь у них есть и собственный порог).
|
||||||
|
# Оставшиеся 10 взяты из `MOSCOW_OBLAST_CITY_ANCHORS` в
|
||||||
|
# packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py — те же
|
||||||
|
# координаты, что использует скрейпер (резолв Nominatim).
|
||||||
|
"Видное": (55.5519, 37.7133),
|
||||||
|
"Балашиха": (55.7969, 37.9386),
|
||||||
|
"Железнодорожный": (55.7444, 38.0128),
|
||||||
|
"Люберцы": (55.6767, 37.8931),
|
||||||
|
"Электросталь": (55.7845, 38.4449),
|
||||||
|
"Химки": (55.8894, 37.4450),
|
||||||
|
"Домодедово": (55.4368, 37.7681),
|
||||||
|
"Ногинск": (55.8554, 38.4412),
|
||||||
|
"Реутов": (55.7614, 37.8564),
|
||||||
|
"Долгопрудный": (55.9386, 37.5100),
|
||||||
|
"Красногорск": (55.8317, 37.3300),
|
||||||
|
"Одинцово": (55.6789, 37.2639),
|
||||||
|
"Подольск": (55.4312, 37.5450),
|
||||||
|
"Сергиев Посад": (56.3153, 38.1360),
|
||||||
|
"Королёв": (55.9142, 37.8256),
|
||||||
|
"Мытищи": (55.9116, 37.7308),
|
||||||
|
"Орехово-Зуево": (55.8062, 38.9810),
|
||||||
|
"Серпухов": (54.9155, 37.4196),
|
||||||
|
"Раменское": (55.5709, 38.2282),
|
||||||
|
"Наро-Фоминск": (55.3843, 36.7235),
|
||||||
|
"Щёлково": (55.9206, 37.9916),
|
||||||
|
"Коломна": (55.0939, 38.7670),
|
||||||
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
|
# Москва — сетка из 67 центроидов, одно display-имя и один порог на все.
|
||||||
# Как сетка получена, почему точек именно столько, где проходит граница
|
# Как сетка получена, почему точек именно столько, где проходит граница
|
||||||
# с областью и какова остаточная цена — см. большой комментарий над
|
# с областью и какова остаточная цена — см. большой комментарий над
|
||||||
|
|
@ -3127,30 +3198,18 @@ _COVERAGE_NEGATIVE_CENTROIDS_DEG: dict[str, tuple[float, float]] = {
|
||||||
"Поварово": (56.0667, 37.0667),
|
"Поварово": (56.0667, 37.0667),
|
||||||
"Дедовск": (55.8672, 37.1200),
|
"Дедовск": (55.8672, 37.1200),
|
||||||
"Нахабино": (55.8500, 37.1833),
|
"Нахабино": (55.8500, 37.1833),
|
||||||
"Реутов": (55.7614, 37.8564),
|
|
||||||
"Подольск": (55.4312, 37.5450),
|
|
||||||
"Апрелевка": (55.5500, 37.0700),
|
"Апрелевка": (55.5500, 37.0700),
|
||||||
"Немчиновка": (55.7050, 37.3450),
|
"Немчиновка": (55.7050, 37.3450),
|
||||||
"Химки": (55.8894, 37.4450),
|
|
||||||
"Одинцово": (55.6789, 37.2639),
|
|
||||||
"Лобня": (56.0100, 37.4750),
|
"Лобня": (56.0100, 37.4750),
|
||||||
"Мытищи": (55.9116, 37.7308),
|
|
||||||
"Котельники": (55.6553, 37.8619),
|
"Котельники": (55.6553, 37.8619),
|
||||||
"Красногорск": (55.8317, 37.3300),
|
|
||||||
"Люберцы": (55.6767, 37.8931),
|
|
||||||
"Дзержинский": (55.6294, 37.8500),
|
"Дзержинский": (55.6294, 37.8500),
|
||||||
"Развилка": (55.5842, 37.7392),
|
"Развилка": (55.5842, 37.7392),
|
||||||
"Климовск": (55.3667, 37.5333),
|
"Климовск": (55.3667, 37.5333),
|
||||||
"Балашиха": (55.7969, 37.9386),
|
|
||||||
"Истра": (55.9167, 36.8667),
|
"Истра": (55.9167, 36.8667),
|
||||||
"Долгопрудный": (55.9386, 37.5100),
|
|
||||||
"Королёв": (55.9142, 37.8256),
|
|
||||||
"Видное": (55.5519, 37.7133),
|
|
||||||
"Селятино": (55.5081, 36.9825),
|
"Селятино": (55.5081, 36.9825),
|
||||||
"Томилино": (55.6528, 37.9472),
|
"Томилино": (55.6528, 37.9472),
|
||||||
"Некрасовский": (56.0500, 37.5500),
|
"Некрасовский": (56.0500, 37.5500),
|
||||||
"Барвиха": (55.7333, 37.2333),
|
"Барвиха": (55.7333, 37.2333),
|
||||||
"Железнодорожный": (55.7444, 38.0128),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -535,19 +535,14 @@ def test_sverdlovsk_resolution_untouched_by_moscow_centroids(
|
||||||
|
|
||||||
# Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов
|
# Крупные подмосковные города, лежавшие внутри 25 км от московских центроидов
|
||||||
# (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву».
|
# (замер на проде 10.09.2026). До правки каждый из них резолвился в «Москву».
|
||||||
|
#
|
||||||
|
# 2026-09-16: 12 из исходных 16 (Химки, Мытищи, Люберцы, Балашиха, Красногорск,
|
||||||
|
# Одинцово, Подольск, Реутов, Королёв, Долгопрудный, Видное, Железнодорожный)
|
||||||
|
# получили собственный порог покрытия — см. COVERAGE_MO_CITIES в
|
||||||
|
# app/api/v1/trade_in.py — и больше не резолвятся в «город не определён».
|
||||||
|
# Здесь остаются только 4 города, которые НЕ замерялись и остаются
|
||||||
|
# отрицательными точками.
|
||||||
_MOSCOW_OBLAST_CITIES = {
|
_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),
|
"Лобня": (56.0100, 37.4750),
|
||||||
"Дзержинский": (55.6294, 37.8500),
|
"Дзержинский": (55.6294, 37.8500),
|
||||||
"Котельники": (55.6553, 37.8619),
|
"Котельники": (55.6553, 37.8619),
|
||||||
|
|
@ -576,7 +571,11 @@ def test_every_negative_centroid_resolves_to_no_city() -> None:
|
||||||
_resolve_coverage_city,
|
_resolve_coverage_city,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 32
|
# 32 - 12: 12 городов (Реутов, Подольск, Химки, Одинцово, Мытищи,
|
||||||
|
# Красногорск, Люберцы, Балашиха, Долгопрудный, Королёв, Видное,
|
||||||
|
# Железнодорожный) переехали в позитивный список 2026-09-16 —
|
||||||
|
# COVERAGE_MO_CITIES, см. app/api/v1/trade_in.py.
|
||||||
|
assert len(_COVERAGE_NEGATIVE_CENTROIDS_DEG) == 20
|
||||||
for name, (lat, lon) in _COVERAGE_NEGATIVE_CENTROIDS_DEG.items():
|
for name, (lat, lon) in _COVERAGE_NEGATIVE_CENTROIDS_DEG.items():
|
||||||
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name
|
assert _resolve_coverage_city(lat, lon) == ("", 0, False), name
|
||||||
|
|
||||||
|
|
@ -618,12 +617,17 @@ def test_moscow_itself_survives_negative_centroids(trade_in_app: FastAPI) -> Non
|
||||||
|
|
||||||
|
|
||||||
def test_moscow_oblast_city_does_not_500_on_the_endpoint(trade_in_app: FastAPI) -> None:
|
def test_moscow_oblast_city_does_not_500_on_the_endpoint(trade_in_app: FastAPI) -> None:
|
||||||
"""Химки через ручку: 200 и not_covered, а не 500 от KeyError по порогу."""
|
"""Истра через ручку: 200 и not_covered, а не 500 от KeyError по порогу.
|
||||||
|
|
||||||
|
Была "Химки" до 2026-09-16 — теперь Химки сама покрытый город
|
||||||
|
(COVERAGE_MO_CITIES), взята следующая точка, которая всё ещё "не
|
||||||
|
определена" (см. комментарий над _MOSCOW_OBLAST_CITIES).
|
||||||
|
"""
|
||||||
db = _db_mock_returning(_row(40, 30.0))
|
db = _db_mock_returning(_row(40, 30.0))
|
||||||
_override(trade_in_app, db)
|
_override(trade_in_app, db)
|
||||||
|
|
||||||
client = TestClient(trade_in_app)
|
client = TestClient(trade_in_app)
|
||||||
lat, lon = _MOSCOW_OBLAST_CITIES["Химки"]
|
lat, lon = _MOSCOW_OBLAST_CITIES["Истра"]
|
||||||
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon})
|
resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, "lat": lat, "lon": lon})
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
@ -678,6 +682,7 @@ def test_sverdlovsk_resolution_untouched_by_negative_centroids(
|
||||||
"""
|
"""
|
||||||
from app.api.v1 import trade_in as trade_in_module
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
|
||||||
|
mo_cities_folded = {trade_in_module._fold_city(c) for c in trade_in_module.COVERAGE_MO_CITIES}
|
||||||
sverdlovsk = {
|
sverdlovsk = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
||||||
|
|
@ -685,6 +690,7 @@ def test_sverdlovsk_resolution_untouched_by_negative_centroids(
|
||||||
not in {
|
not in {
|
||||||
trade_in_module._fold_city(m) for m in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
trade_in_module._fold_city(m) for m in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
||||||
}
|
}
|
||||||
|
and trade_in_module._fold_city(k) not in mo_cities_folded
|
||||||
}
|
}
|
||||||
assert len(sverdlovsk) == 9
|
assert len(sverdlovsk) == 9
|
||||||
|
|
||||||
|
|
@ -753,22 +759,26 @@ _MOSCOW_CONTROLS = {
|
||||||
"Троицк (ТАО)": (55.4853, 37.3050),
|
"Троицк (ТАО)": (55.4853, 37.3050),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Областной контрольный набор (32 города). 16 из них имеют собственный
|
# Областной контрольный набор. Изначально 32 города (16 из _MOSCOW_OBLAST_CITIES
|
||||||
# отрицательный центроид и выигрывают им; остальные 16 держатся только
|
# + 16 ниже) — часть держалась только радиусом московских точек, включая
|
||||||
# радиусом московских точек — включая Наро-Фоминск (9.95 км до ближайшей
|
# Наро-Фоминск (9.95 км до ближайшей точки сетки), Кубинку (17.2) и Чехов
|
||||||
# точки сетки), Кубинку (17.2) и Чехов (22.3), которые при глобальных 25 км
|
# (22.3), которые при глобальных 25 км резолвились как «Москва». Это и есть
|
||||||
# резолвились как «Москва». Это и есть дефект #1.
|
# дефект #1.
|
||||||
|
#
|
||||||
|
# 2026-09-16: Наро-Фоминск, Домодедово, Жуковский, Щёлково и Пушкино убраны
|
||||||
|
# из набора. Наро-Фоминск/Домодедово/Щёлково сами стали покрытыми городами
|
||||||
|
# (COVERAGE_MO_CITIES). Жуковский и Пушкино не входят в COVERAGE_MO_CITIES, но
|
||||||
|
# оказались внутри 25-километрового радиуса новых соседних центроидов
|
||||||
|
# (Раменское и Королёв соответственно) и больше не резолвятся в «город не
|
||||||
|
# определён». Пушкино используется отдельно (см. `_PUSHKINO_COORDS`) —
|
||||||
|
# test_phantom_exclave_no_longer_gives_moscow_to_pushkino проверяет именно то,
|
||||||
|
# что ближайший центроид для него теперь Королёв, а не пустота.
|
||||||
_MOSCOW_OBLAST_CONTROLS = {
|
_MOSCOW_OBLAST_CONTROLS = {
|
||||||
**_MOSCOW_OBLAST_CITIES,
|
**_MOSCOW_OBLAST_CITIES,
|
||||||
"Наро-Фоминск": (55.3869, 36.7331),
|
|
||||||
"Кубинка": (55.5794, 36.7000),
|
"Кубинка": (55.5794, 36.7000),
|
||||||
"Чехов": (55.1477, 37.4770),
|
"Чехов": (55.1477, 37.4770),
|
||||||
"Голицыно": (55.6167, 36.9833),
|
"Голицыно": (55.6167, 36.9833),
|
||||||
"Лыткарино": (55.5833, 37.9000),
|
"Лыткарино": (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.7300, 36.8500),
|
||||||
"Апрелевка": (55.5500, 37.0700),
|
"Апрелевка": (55.5500, 37.0700),
|
||||||
"Селятино": (55.5081, 36.9825),
|
"Селятино": (55.5081, 36.9825),
|
||||||
|
|
@ -778,6 +788,10 @@ _MOSCOW_OBLAST_CONTROLS = {
|
||||||
"Барвиха": (55.7333, 37.2333),
|
"Барвиха": (55.7333, 37.2333),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Пушкино вынесено из _MOSCOW_OBLAST_CONTROLS 2026-09-16 (см. комментарий
|
||||||
|
# выше) — используется test_phantom_exclave_no_longer_gives_moscow_to_pushkino.
|
||||||
|
_PUSHKINO_COORDS = (56.0100, 37.8500)
|
||||||
|
|
||||||
# Точки, добавленные замером 11.09.2026: жилые районы, целиком проигрывавшие
|
# Точки, добавленные замером 11.09.2026: жилые районы, целиком проигрывавшие
|
||||||
# конкурс подмосковной точке. Проверяются по координатам из самого модуля,
|
# конкурс подмосковной точке. Проверяются по координатам из самого модуля,
|
||||||
# ключ здесь — только адрес строки.
|
# ключ здесь — только адрес строки.
|
||||||
|
|
@ -854,10 +868,10 @@ def test_whole_moscow_control_set_answers_through_the_endpoint(trade_in_app: Fas
|
||||||
|
|
||||||
|
|
||||||
def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastAPI) -> None:
|
def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastAPI) -> None:
|
||||||
"""Все 32 областных контроля → «город не определён», и ручка отдаёт 200."""
|
"""Все 15 областных контролей → «город не определён», и ручка отдаёт 200."""
|
||||||
from app.api.v1.trade_in import _resolve_coverage_city
|
from app.api.v1.trade_in import _resolve_coverage_city
|
||||||
|
|
||||||
assert len(_MOSCOW_OBLAST_CONTROLS) == 32
|
assert len(_MOSCOW_OBLAST_CONTROLS) == 15
|
||||||
failed = {
|
failed = {
|
||||||
name: _resolve_coverage_city(lat, lon)
|
name: _resolve_coverage_city(lat, lon)
|
||||||
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items()
|
for name, (lat, lon) in _MOSCOW_OBLAST_CONTROLS.items()
|
||||||
|
|
@ -878,15 +892,18 @@ def test_whole_moscow_oblast_control_set_resolves_to_no_city(trade_in_app: FastA
|
||||||
|
|
||||||
|
|
||||||
def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
|
def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
|
||||||
"""Дефект #1 предметно: Наро-Фоминск, Кубинка, Чехов.
|
"""Дефект #1 предметно: Кубинка, Чехов.
|
||||||
|
|
||||||
С одним глобальным радиусом 25 км круги плотной сетки складывались, и все
|
С одним глобальным радиусом 25 км круги плотной сетки складывались, и обе
|
||||||
трое получали «Москву», хотя до ближайшей точки сетки им 9.95, 17.2 и
|
получали «Москву», хотя до ближайшей точки сетки им 17.2 и 22.3 км. С
|
||||||
22.3 км. С радиусом-свойством (8 км у московских точек) — «не определён».
|
радиусом-свойством (8 км у московских точек) — «не определён».
|
||||||
|
|
||||||
|
Наро-Фоминск убран из проверки 2026-09-16: сам стал покрытым городом
|
||||||
|
(COVERAGE_MO_CITIES), дефект #1 больше не демонстрирует.
|
||||||
"""
|
"""
|
||||||
from app.api.v1.trade_in import _resolve_coverage_city
|
from app.api.v1.trade_in import _resolve_coverage_city
|
||||||
|
|
||||||
for name in ("Наро-Фоминск", "Кубинка", "Чехов"):
|
for name in ("Кубинка", "Чехов"):
|
||||||
lat, lon = _MOSCOW_OBLAST_CONTROLS[name]
|
lat, lon = _MOSCOW_OBLAST_CONTROLS[name]
|
||||||
assert _legacy_resolve(lat, lon) == ("Москва", 12, True), (
|
assert _legacy_resolve(lat, lon) == ("Москва", 12, True), (
|
||||||
f"{name}: тест перестал воспроизводить дефект, сравнивать не с чем"
|
f"{name}: тест перестал воспроизводить дефект, сравнивать не с чем"
|
||||||
|
|
@ -897,13 +914,15 @@ def test_deep_oblast_towns_were_moscow_under_one_global_radius() -> None:
|
||||||
def test_radius_is_a_property_of_the_centroid_not_a_global_constant() -> None:
|
def test_radius_is_a_property_of_the_centroid_not_a_global_constant() -> None:
|
||||||
"""Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской.
|
"""Одно и то же расстояние даёт РАЗНЫЙ ответ в Москве и в Свердловской.
|
||||||
|
|
||||||
Наро-Фоминск в 9.95 км от ближайшей точки московской сетки — «не
|
Кубинка в 17.2 км от ближайшей точки московской сетки — «не определён»
|
||||||
определён». Точка в 20 км южнее центра Екатеринбурга (то есть ДАЛЬШЕ) —
|
(была "Наро-Фоминск" до 2026-09-16 — тот сам стал покрытым городом,
|
||||||
«Екатеринбург». Глобальной константой такое поведение невыразимо.
|
COVERAGE_MO_CITIES). Точка в 20 км южнее центра Екатеринбурга (то есть
|
||||||
|
ДАЛЬШЕ) — «Екатеринбург». Глобальной константой такое поведение
|
||||||
|
невыразимо.
|
||||||
"""
|
"""
|
||||||
from app.api.v1 import trade_in as trade_in_module
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
|
||||||
lat, lon = _MOSCOW_OBLAST_CONTROLS["Наро-Фоминск"]
|
lat, lon = _MOSCOW_OBLAST_CONTROLS["Кубинка"]
|
||||||
km_to_grid = min(
|
km_to_grid = min(
|
||||||
trade_in_module._haversine_km(lat, lon, glat, glon)
|
trade_in_module._haversine_km(lat, lon, glat, glon)
|
||||||
for glat, glon in trade_in_module._MOSCOW_GRID_DEG.values()
|
for glat, glon in trade_in_module._MOSCOW_GRID_DEG.values()
|
||||||
|
|
@ -942,10 +961,12 @@ def test_sverdlovsk_resolution_identical_to_one_global_radius() -> None:
|
||||||
moscow_keys = {
|
moscow_keys = {
|
||||||
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
trade_in_module._fold_city(k) for k in trade_in_module.COVERAGE_MOSCOW_CENTROID_KEYS
|
||||||
}
|
}
|
||||||
|
mo_cities_folded = {trade_in_module._fold_city(c) for c in trade_in_module.COVERAGE_MO_CITIES}
|
||||||
sverdlovsk = {
|
sverdlovsk = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
for k, v in trade_in_module._CITY_CENTROIDS_DEG.items()
|
||||||
if trade_in_module._fold_city(k) not in moscow_keys
|
if trade_in_module._fold_city(k) not in moscow_keys
|
||||||
|
and trade_in_module._fold_city(k) not in mo_cities_folded
|
||||||
}
|
}
|
||||||
assert len(sverdlovsk) == 9
|
assert len(sverdlovsk) == 9
|
||||||
for clat, clon in sverdlovsk.values():
|
for clat, clon in sverdlovsk.values():
|
||||||
|
|
@ -973,14 +994,18 @@ def test_phantom_exclave_no_longer_gives_moscow_to_pushkino(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой».
|
"""Точка-призрак выброшена, и без неё Пушкино перестало быть «Москвой».
|
||||||
|
|
||||||
Возвращаем призрак в таблицу — Пушкино снова резолвится в «Москву»: это
|
2026-09-16: Королёв (COVERAGE_MO_CITIES) стал позитивным центроидом в
|
||||||
доказывает, что дефект чинит именно выброс точки, а не что-нибудь ещё.
|
~10.76 км от Пушкино — ближе прежнего "not_covered", поэтому базовый
|
||||||
|
ответ без призрака теперь «Королёв», а не пустота. Возвращаем призрак в
|
||||||
|
таблицу — он в 3.06 км, ближе Королёва, и Пушкино снова резолвится в
|
||||||
|
«Москву»: это по-прежнему доказывает, что дефект чинит именно выброс
|
||||||
|
точки, а не что-нибудь ещё.
|
||||||
"""
|
"""
|
||||||
from app.api.v1 import trade_in as trade_in_module
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
|
||||||
pushkino = _MOSCOW_OBLAST_CONTROLS["Пушкино"]
|
pushkino = _PUSHKINO_COORDS
|
||||||
assert trade_in_module._resolve_coverage_city(*_PHANTOM_EXCLAVE) != ("Москва", 12, True)
|
assert trade_in_module._resolve_coverage_city(*_PHANTOM_EXCLAVE) != ("Москва", 12, True)
|
||||||
assert trade_in_module._resolve_coverage_city(*pushkino) == ("", 0, False)
|
assert trade_in_module._resolve_coverage_city(*pushkino) == ("Королёв", 12, True)
|
||||||
|
|
||||||
ghost_key = "Москва (Восточный, эксклав)"
|
ghost_key = "Москва (Восточный, эксклав)"
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -1017,12 +1042,31 @@ def test_added_grid_points_return_districts_lost_to_negative_centroids(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Каждая добавленная точка что-то возвращает: без неё район теряется.
|
"""Каждая добавленная точка что-то возвращает: без неё район теряется.
|
||||||
|
|
||||||
Для всех 12 точек: в самой точке резолвер отдаёт «Москву», а если убрать
|
Для всех 12 точек: в самой точке резолвер отдаёт «Москву». Без неё точка
|
||||||
её из таблицы — «город не определён» (район уходит подмосковному центру).
|
проигрывает следующему ближайшему центроиду — раньше это всегда было
|
||||||
Мёртвых строк в добавке нет.
|
«город не определён», но с 2026-09-16 (COVERAGE_MO_CITIES) для 9 из 12
|
||||||
|
точек следующий ближайший — новый позитивный центроид подмосковного
|
||||||
|
города, а не пустота. Инвариант, который всё ещё держит тест: ни одна
|
||||||
|
точка не деградирует ОБРАТНО в «Москву» — иначе строка была бы мёртвой.
|
||||||
"""
|
"""
|
||||||
from app.api.v1 import trade_in as trade_in_module
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
|
||||||
|
expected_fallback = {
|
||||||
|
"Москва (Восточный)": ("Балашиха", 12, True),
|
||||||
|
"Москва (Левобережный)": ("Химки", 12, True),
|
||||||
|
"Москва (Орехово-Борисово Южное)": ("", 0, False),
|
||||||
|
"Москва (Северный)": ("Долгопрудный", 12, True),
|
||||||
|
"Москва (Митино-запад)": ("Красногорск", 12, True),
|
||||||
|
"Москва (Ивановское)": ("Реутов", 12, True),
|
||||||
|
"Москва (Жулебино)": ("Люберцы", 12, True),
|
||||||
|
"Москва (Можайский-запад)": ("", 0, False),
|
||||||
|
"Москва (Новокосино)": ("Реутов", 12, True),
|
||||||
|
"Москва (Капотня)": ("", 0, False),
|
||||||
|
"Москва (Куркино)": ("Химки", 12, True),
|
||||||
|
"Москва (Некрасовка-восток)": ("Люберцы", 12, True),
|
||||||
|
}
|
||||||
|
assert set(expected_fallback) == set(_ADDED_GRID_KEYS)
|
||||||
|
|
||||||
full = trade_in_module._CITY_CENTROIDS_DEG
|
full = trade_in_module._CITY_CENTROIDS_DEG
|
||||||
for key in _ADDED_GRID_KEYS:
|
for key in _ADDED_GRID_KEYS:
|
||||||
lat, lon = full[key]
|
lat, lon = full[key]
|
||||||
|
|
@ -1032,9 +1076,11 @@ def test_added_grid_points_return_districts_lost_to_negative_centroids(
|
||||||
"_CITY_CENTROIDS_DEG",
|
"_CITY_CENTROIDS_DEG",
|
||||||
{k: v for k, v in full.items() if k != key},
|
{k: v for k, v in full.items() if k != key},
|
||||||
)
|
)
|
||||||
assert trade_in_module._resolve_coverage_city(lat, lon) == ("", 0, False), (
|
fallback = trade_in_module._resolve_coverage_city(lat, lon)
|
||||||
f"{key} ничего не возвращает — строка мёртвая"
|
assert fallback == expected_fallback[key], (
|
||||||
|
f"{key}: ожидали {expected_fallback[key]}, получили {fallback}"
|
||||||
)
|
)
|
||||||
|
assert fallback[0] != "Москва", f"{key} деградировала обратно в «Москву»"
|
||||||
monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", full)
|
monkeypatch.setattr(trade_in_module, "_CITY_CENTROIDS_DEG", full)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1047,10 +1093,16 @@ def test_negative_centroid_radius_is_unbounded_and_endpoint_survives_it(
|
||||||
расстоянии означает «город не определён»: точка в глухом углу области,
|
расстоянии означает «город не определён»: точка в глухом углу области,
|
||||||
где ближайший центр — подмосковный и дальше 25 км, отдаёт not_covered
|
где ближайший центр — подмосковный и дальше 25 км, отдаёт not_covered
|
||||||
и 200, а не 500 по отсутствующему порогу.
|
и 200, а не 500 по отсутствующему порогу.
|
||||||
|
|
||||||
|
Координаты сменены 2026-09-16: прежняя точка (55.30, 38.75) после
|
||||||
|
добавления COVERAGE_MO_CITIES стала ближе к Коломне (22.9 км, позитивный
|
||||||
|
центроид), чем к любой отрицательной точке — тест проверял бы Коломну, а
|
||||||
|
не бесконечный радиус. Новая точка ближе всего к «Поварово» (26.27 км,
|
||||||
|
отрицательный центроид).
|
||||||
"""
|
"""
|
||||||
from app.api.v1 import trade_in as trade_in_module
|
from app.api.v1 import trade_in as trade_in_module
|
||||||
|
|
||||||
far_lat, far_lon = 55.30, 38.75
|
far_lat, far_lon = 56.30, 37.00
|
||||||
nearest_km, nearest_name = min(
|
nearest_km, nearest_name = min(
|
||||||
(trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name)
|
(trade_in_module._haversine_km(far_lat, far_lon, clat, clon), name)
|
||||||
for name, (clat, clon) in (
|
for name, (clat, clon) in (
|
||||||
|
|
|
||||||
|
|
@ -561,6 +561,7 @@ def test_offered_cities_match_coverage_cities() -> None:
|
||||||
|
|
||||||
from app.api.v1.trade_in import (
|
from app.api.v1.trade_in import (
|
||||||
COVERAGE_GREEN_CITIES,
|
COVERAGE_GREEN_CITIES,
|
||||||
|
COVERAGE_MO_CITIES,
|
||||||
COVERAGE_MOSCOW_DISPLAY,
|
COVERAGE_MOSCOW_DISPLAY,
|
||||||
COVERAGE_YELLOW_CITIES,
|
COVERAGE_YELLOW_CITIES,
|
||||||
_fold_city,
|
_fold_city,
|
||||||
|
|
@ -580,7 +581,12 @@ def test_offered_cities_match_coverage_cities() -> None:
|
||||||
offered = {_fold_city(m) for m in re.findall(r'label:\s*"([^"]+)"', block.group(1))}
|
offered = {_fold_city(m) for m in re.findall(r'label:\s*"([^"]+)"', block.group(1))}
|
||||||
answerable = {
|
answerable = {
|
||||||
_fold_city(c)
|
_fold_city(c)
|
||||||
for c in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES, COVERAGE_MOSCOW_DISPLAY)
|
for c in (
|
||||||
|
*COVERAGE_GREEN_CITIES,
|
||||||
|
*COVERAGE_YELLOW_CITIES,
|
||||||
|
*COVERAGE_MO_CITIES,
|
||||||
|
COVERAGE_MOSCOW_DISPLAY,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert offered == answerable, (
|
assert offered == answerable, (
|
||||||
|
|
@ -595,12 +601,13 @@ def test_every_coverage_city_has_a_centroid() -> None:
|
||||||
from app.api.v1.trade_in import (
|
from app.api.v1.trade_in import (
|
||||||
_CITY_CENTROIDS_DEG,
|
_CITY_CENTROIDS_DEG,
|
||||||
COVERAGE_GREEN_CITIES,
|
COVERAGE_GREEN_CITIES,
|
||||||
|
COVERAGE_MO_CITIES,
|
||||||
COVERAGE_YELLOW_CITIES,
|
COVERAGE_YELLOW_CITIES,
|
||||||
_fold_city,
|
_fold_city,
|
||||||
)
|
)
|
||||||
|
|
||||||
centroids = {_fold_city(c) for c in _CITY_CENTROIDS_DEG}
|
centroids = {_fold_city(c) for c in _CITY_CENTROIDS_DEG}
|
||||||
for city in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES):
|
for city in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES, *COVERAGE_MO_CITIES):
|
||||||
assert _fold_city(city) in centroids, (
|
assert _fold_city(city) in centroids, (
|
||||||
f"{city} есть в порогах, но нет в центроидах — по координатам он "
|
f"{city} есть в порогах, но нет в центроидах — по координатам он "
|
||||||
"никогда не резолвится, и порог мёртвый"
|
"никогда не резолвится, и порог мёртвый"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* city-registry — единый источник правды по городам покрытия «Меры»:
|
* city-registry — единый источник правды по городам покрытия «Меры»:
|
||||||
* Свердловская область (регион 66) и Москва (регион 77, PR-B).
|
* Свердловская область (регион 66), Москва (регион 77, PR-B) и Московская
|
||||||
|
* область (регион 50, 22 города — расширение 2026-09-16: замер плотности
|
||||||
|
* когорты на проде показал, что каждый город области плотнее уже-жёлтой
|
||||||
|
* Москвы, см. `COVERAGE_MO_CITIES` в backend/app/api/v1/trade_in.py).
|
||||||
*
|
*
|
||||||
* Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного
|
* Раньше геокодер молча подставлял "Екатеринбург, " в любой запрос без явного
|
||||||
* города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал
|
* города в тексте адреса — житель Нижнего Тагила, вводя «Ленина, 1», получал
|
||||||
|
|
@ -49,11 +52,12 @@ export interface OblastCity {
|
||||||
*/
|
*/
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
/**
|
/**
|
||||||
* Регион: 66 — Свердловская область, 77 — Москва. НЕ отправляется бэкенду —
|
* Регион: 66 — Свердловская область, 77 — Москва, 50 — Московская область.
|
||||||
* нужен только фронту, чтобы отличить «частичное покрытие области» от
|
* НЕ отправляется бэкенду — нужен только фронту, чтобы отличить «частичное
|
||||||
* отдельного региона. Большинство потребителей читают только id/label.
|
* покрытие области» от отдельного региона. Большинство потребителей читают
|
||||||
|
* только id/label.
|
||||||
*/
|
*/
|
||||||
readonly region: 66 | 77;
|
readonly region: 66 | 77 | 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок
|
// ЕКБ первым — это DEFAULT_CITY (см. ниже) и основной трафик; порядок
|
||||||
|
|
@ -69,12 +73,41 @@ export const OBLAST_CITIES: readonly OblastCity[] = [
|
||||||
{ id: "revda", label: "Ревда", region: 66 },
|
{ id: "revda", label: "Ревда", region: 66 },
|
||||||
{ id: "serov", label: "Серов", region: 66 },
|
{ id: "serov", label: "Серов", region: 66 },
|
||||||
{ id: "moscow", label: "Москва", region: 77 },
|
{ id: "moscow", label: "Москва", region: 77 },
|
||||||
|
// Московская область (регион 50) — 22 города, расширение 2026-09-16.
|
||||||
|
// Все получают жёлтый тир (COVERAGE_YELLOW_MIN_N): замер 120 проб/город,
|
||||||
|
// радиус 8 км, когорта = is_active + точные комнаты + площадь ±15% +
|
||||||
|
// scraped_at > now()-14d + ST_DWithin 1000 м. Медиана когорты 17-51,
|
||||||
|
// доля проб с ≥12 аналогов 0.63-0.92 — у каждого города выше, чем у уже
|
||||||
|
// жёлтой Москвы (медиана 14, доля 0.57). Порядок — по убыванию медианы.
|
||||||
|
{ id: "vidnoe", label: "Видное", region: 50 },
|
||||||
|
{ id: "balashikha", label: "Балашиха", region: 50 },
|
||||||
|
{ id: "zheleznodorozhny", label: "Железнодорожный", region: 50 },
|
||||||
|
{ id: "lyubertsy", label: "Люберцы", region: 50 },
|
||||||
|
{ id: "elektrostal", label: "Электросталь", region: 50 },
|
||||||
|
{ id: "khimki", label: "Химки", region: 50 },
|
||||||
|
{ id: "domodedovo", label: "Домодедово", region: 50 },
|
||||||
|
{ id: "noginsk", label: "Ногинск", region: 50 },
|
||||||
|
{ id: "reutov", label: "Реутов", region: 50 },
|
||||||
|
{ id: "dolgoprudny", label: "Долгопрудный", region: 50 },
|
||||||
|
{ id: "krasnogorsk", label: "Красногорск", region: 50 },
|
||||||
|
{ id: "odintsovo", label: "Одинцово", region: 50 },
|
||||||
|
{ id: "podolsk", label: "Подольск", region: 50 },
|
||||||
|
{ id: "sergiev_posad", label: "Сергиев Посад", region: 50 },
|
||||||
|
{ id: "korolev", label: "Королёв", region: 50 },
|
||||||
|
{ id: "mytishchi", label: "Мытищи", region: 50 },
|
||||||
|
{ id: "orekhovo_zuevo", label: "Орехово-Зуево", region: 50 },
|
||||||
|
{ id: "serpukhov", label: "Серпухов", region: 50 },
|
||||||
|
{ id: "ramenskoye", label: "Раменское", region: 50 },
|
||||||
|
{ id: "naro_fominsk", label: "Наро-Фоминск", region: 50 },
|
||||||
|
{ id: "schelkovo", label: "Щёлково", region: 50 },
|
||||||
|
{ id: "kolomna", label: "Коломна", region: 50 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** RU-имена регионов покрытия — та же пара кодов, что `OblastCity.region`. */
|
/** RU-имена регионов покрытия — та же пара кодов, что `OblastCity.region`. */
|
||||||
export const REGION_LABELS: Record<66 | 77, string> = {
|
export const REGION_LABELS: Record<66 | 77 | 50, string> = {
|
||||||
66: "Свердловская область",
|
66: "Свердловская область",
|
||||||
77: "Москва",
|
77: "Москва",
|
||||||
|
50: "Московская область",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Только область (регион 66) — прежнее множество, до добавления Москвы. */
|
/** Только область (регион 66) — прежнее множество, до добавления Москвы. */
|
||||||
|
|
@ -82,8 +115,20 @@ export const OBLAST_66_CITIES: readonly OblastCity[] = OBLAST_CITIES.filter(
|
||||||
(c) => c.region === 66,
|
(c) => c.region === 66,
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Москва одним объектом — тем же паттерном, что `DEFAULT_CITY = OBLAST_CITIES[0]` ниже. */
|
/**
|
||||||
export const MOSCOW_CITY: OblastCity = OBLAST_CITIES[OBLAST_CITIES.length - 1];
|
* Москва одним объектом. Раньше бралась по индексу последнего элемента
|
||||||
|
* `OBLAST_CITIES` (работало, пока Москва была последней в списке) — после
|
||||||
|
* добавления 22 городов Московской области (регион 50) последним элементом
|
||||||
|
* стала Коломна, индекс молча указывал бы не на тот город. Ищем по
|
||||||
|
* стабильному `id`, а не по позиции в массиве.
|
||||||
|
*/
|
||||||
|
export const MOSCOW_CITY: OblastCity = (() => {
|
||||||
|
const city = OBLAST_CITIES.find((c) => c.id === "moscow");
|
||||||
|
if (!city) {
|
||||||
|
throw new Error("city-registry: 'moscow' отсутствует в OBLAST_CITIES");
|
||||||
|
}
|
||||||
|
return city;
|
||||||
|
})();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и
|
* Дефолт формы — Екатеринбург (визуальный/best-guess, для текста подсказки и
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue