Три куска, каждый нужен, чтобы поиск и оценка по Москве заработали end-to-end. 1. Импортёр msk_raw -> listings (app/tasks/msk_raw_import.py). Переиспользует штатный save_listings из кита: писатель уже параметризован регионом, свой не нужен. payload в msk_raw — сериализованный ScrapedLot один в один, так что импорт сводится к сборке модели и вызову писателя. Москва отбирается по префиксу административного округа в адресе, а не по bbox. Причина: адрес Циан не содержит города, а границы региона 77 захватывают ближний пояс области. Замер по проду: с округом 35 552, все внутри bbox 77; без округа внутри bbox 17 576 — это область. Отдельно отсекаются 212 карточек с адресом «Екатеринбург (Cian)», артефакт парсера. listing_segment ПЕРЕСЧИТЫВАЕТСЯ перед записью, а не копируется из payload. Кит ставит novostroyki по одному наличию offer.newbuilding.id. Замер по всем 60 464 карточкам: is_from_developer=true у НУЛЯ, false у 29 000, отсутствует у 31 464. Застройщик не продаёт ни одной карточки корпуса. В проде есть гвард (estimator.py): в аналоги идут строки только с listing_segment IS NULL или 'vtorichka' — копирование метки как есть выбросило бы 29 000 строк из подбора. Авито импортируется только с явным --allow-unfiltered: в его адресе нет ни города, ни округа, координат нет ни у одной из 50 335 карточек, отличить область от Москвы нечем. Прогон на проде: прочитано 60 464, записано 35 552, все с геометрией, 35 182 привязаны к дому, создано 10 960 домов. Повторный проход строки не дублирует — idempotency на dedup_hash, проверено. 2. Подсказки адреса стали региональными (geocoder.suggest, api/v1/geocode). Раньше suggest вообще не принимал регион: DaData звалась с жёстким region='Свердловская', Nominatim — с viewbox 66-го и bounded=1. Московский адрес давал ПУСТОЙ список молча, без ошибки; в коде это уже было описано как известный баг. Механику по регионам переиспользовали из geocode(), вторую не писали. Кадастровый тир для не-66 не зовётся: он на ЕКБ-данных. 3. Оценка перестала геокодировать Москву свердловским скоупом (estimator). geocode() звалась без региона, то есть с дефолтом 66, и московский адрес возвращал бы пустую оценку с причиной address_not_geocoded даже с рабочими подсказками. Регион запроса определяется по координатам через реестр, затем по city_hint, затем дефолт. Fast-path клиентских координат стал региононезависимым: OBLAST66_BBOX и REGIONS[66].bbox_region совпадают байт-в-байт, поэтому для 66 поведение прежнее, добавились координаты Москвы. Регресс-нейтральность по Свердловской области — главный критерий всех трёх кусков. Тесты: 1160 passed по затронутым областям. Известные ограничения. Границы 77 захватывают ближний пояс области, Химки резолвятся в Москву. У региона 77 нет ни одного тира обогащения, оценка поедет на аналогах и сделках. Ценовая полоса по Москве одна на весь город. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
132 lines
6.1 KiB
Python
132 lines
6.1 KiB
Python
"""#3051: скоуп региона в estimate — fast-path и вызов geocode().
|
||
|
||
ПОЧЕМУ ЭТО ТЕСТ. Оба тира geocode() ограничены регионом ЖЁСТКИМ фильтром
|
||
(DaData `locations.region`, Nominatim `viewbox`+`bounded=1`), а не бустом:
|
||
промах региона не даёт ошибки — выдача схлопывается в пустую, и estimate
|
||
возвращает `_empty_estimate(reason='address_not_geocoded')`, неотличимую от
|
||
«такого адреса нет». Значит проверять надо не результат, а ЧТО именно уходит
|
||
в geocode(). Второй предмет — регресс-нейтральность 66: без московских данных
|
||
поведение обязано быть прежним.
|
||
|
||
Сеть и БД не дёргаем: geocode и _empty_estimate мокаются, db — MagicMock.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||
|
||
import pytest
|
||
|
||
from app.schemas.trade_in import TradeInEstimateInput
|
||
from app.services.estimator import _request_region_code, estimate_quality
|
||
|
||
pytestmark = pytest.mark.anyio
|
||
|
||
# Тверская 6 (Москва) и Малышева 30 (Екатеринбург) — точки внутри bbox_region
|
||
# соответствующих регионов реестра.
|
||
MSK = (55.7605, 37.6100)
|
||
EKB = (56.8380, 60.6000)
|
||
|
||
|
||
def _payload(**kw) -> TradeInEstimateInput:
|
||
base = {"address": "Тверская 6", "area_m2": 50.0, "rooms": 2}
|
||
base.update(kw)
|
||
return TradeInEstimateInput(**base)
|
||
|
||
|
||
# ── _request_region_code: приоритет координаты → city_hint → 66 ──────────────
|
||
|
||
|
||
def test_region_from_moscow_coords() -> None:
|
||
assert _request_region_code(_payload(lat=MSK[0], lon=MSK[1])) == 77
|
||
|
||
|
||
def test_region_from_ekb_coords() -> None:
|
||
assert _request_region_code(_payload(lat=EKB[0], lon=EKB[1])) == 66
|
||
|
||
|
||
def test_region_without_coords_defaults_to_66() -> None:
|
||
"""Нет координат и нет узнаваемого города — прежний дефолт 66."""
|
||
assert _request_region_code(_payload()) == 66
|
||
assert _request_region_code(_payload(city_hint="Урюпинск")) == 66
|
||
|
||
|
||
def test_region_from_city_hint() -> None:
|
||
assert _request_region_code(_payload(city_hint="Москва")) == 77
|
||
assert _request_region_code(_payload(city_hint="Нижний Тагил")) == 66
|
||
|
||
|
||
def test_region_coords_outside_any_region_default_66() -> None:
|
||
"""Точка вне охвата (Сочи) → 66, а НЕ None: NULL обнулил бы фильтр."""
|
||
assert _request_region_code(_payload(lat=43.6, lon=39.7)) == 66
|
||
|
||
|
||
# ── fast-path клиентских координат: региононезависимость ─────────────────────
|
||
|
||
|
||
async def _run_estimate(payload: TradeInEstimateInput):
|
||
"""estimate_quality до первой развилки: geocode → None → _empty_estimate.
|
||
|
||
Если fast-path принял клиентские координаты, функция идёт дальше, в счёт по
|
||
БД, и спотыкается о MagicMock-сессию — это ожидаемо и подавляется: предмет
|
||
проверки здесь ровно один, БЫЛ ли вызван geocode() и с каким регионом.
|
||
"""
|
||
geocode_mock = AsyncMock(return_value=None)
|
||
empty_mock = MagicMock(return_value="EMPTY")
|
||
result = None
|
||
with (
|
||
patch("app.services.estimator.geocode", new=geocode_mock),
|
||
patch("app.services.estimator._empty_estimate", new=empty_mock),
|
||
):
|
||
try:
|
||
result = await estimate_quality(payload, MagicMock())
|
||
except Exception: # дальше по функции живая БД, см. докстринг
|
||
pass
|
||
return geocode_mock, empty_mock, result
|
||
|
||
|
||
async def test_fast_path_accepts_moscow_coords() -> None:
|
||
"""Московские координаты принимаются как клиентские — geocode не зовём."""
|
||
geocode_mock, _, _ = await _run_estimate(_payload(lat=MSK[0], lon=MSK[1]))
|
||
geocode_mock.assert_not_awaited()
|
||
|
||
|
||
async def test_fast_path_accepts_ekb_coords_unchanged() -> None:
|
||
"""Регресс 66: координаты области по-прежнему минуют geocode()."""
|
||
geocode_mock, _, _ = await _run_estimate(_payload(lat=EKB[0], lon=EKB[1]))
|
||
geocode_mock.assert_not_awaited()
|
||
|
||
|
||
async def test_fast_path_ignores_coords_outside_coverage() -> None:
|
||
"""Точка вне охвата (Сочи) — как и раньше, честный geocode()."""
|
||
geocode_mock, _, _ = await _run_estimate(_payload(lat=43.6, lon=39.7))
|
||
geocode_mock.assert_awaited_once()
|
||
|
||
|
||
# ── geocode() получает регион запроса ────────────────────────────────────────
|
||
|
||
|
||
async def test_geocode_gets_region_77_for_moscow() -> None:
|
||
geocode_mock, empty_mock, result = await _run_estimate(
|
||
_payload(address="Тверская 6", city_hint="Москва")
|
||
)
|
||
assert geocode_mock.await_args.kwargs["region_code"] == 77
|
||
assert result == "EMPTY"
|
||
assert empty_mock.call_args.kwargs["reason"] == "address_not_geocoded"
|
||
|
||
|
||
async def test_geocode_gets_region_66_for_ekb() -> None:
|
||
geocode_mock, _, _ = await _run_estimate(
|
||
_payload(address="Малышева 30", city_hint="Екатеринбург")
|
||
)
|
||
assert geocode_mock.await_args.kwargs["region_code"] == 66
|
||
|
||
|
||
async def test_geocode_default_region_66_without_hints() -> None:
|
||
"""Регресс-нейтральность: без координат и city_hint — прежний скоуп 66."""
|
||
geocode_mock, _, _ = await _run_estimate(_payload(address="Малышева 30"))
|
||
assert geocode_mock.await_args.kwargs["region_code"] == 66
|
||
assert geocode_mock.await_args.kwargs["city_hint"] is None
|