gendesign/tradein-mvp/backend/tests/test_3051_estimate_region_scope.py
bot-backend b1727ca39c feat(msk): импорт сырья по Москве в listings и region-aware геокодирование
Три куска, каждый нужен, чтобы поиск и оценка по Москве заработали 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
2026-09-10 18:50:37 +03:00

132 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"""#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