gendesign/tradein-mvp/backend/tests/test_street_scheme.py
bot-backend ca3f073b0e feat(mera/b2c): улица сделки доезжает до витрины готовой схемой, а не геометрией
Карта в карточке игры показывала полигон района — единственную геометрию, до
которой у tradein был доступ. Улицы живут в базе gendesign, foreign table и
гранта на них не было.

Мост по образцу соседей (v_tradein_cad_buildings / v_tradein_osm_poi_ekb):
gendesign 195 — вьюха v_tradein_osm_roads_ekb (highway + water) с GRANT В ТОМ
ЖЕ ФАЙЛЕ (после #3227 грант отдельной миграцией теряется при пересоздании);
tradein 281 — foreign table gendesign_osm_roads_ekb плюс колонки street_name /
street_scheme в landing_showcase_deals.

Пересчёт витрины кладёт в строку УЖЕ СПРОЕЦИРОВАННЫЕ SVG-пути окна 840x840 м
вокруг центра улицы. Проекция — та же равнопромежуточная с cos(широты), что в
export_ekb_districts_svg.py; второй в проекте нет. Замер 2026-08-29: GeoJSON
того же окна 7-8 КБ на строку, схема — 2.8-2.9 КБ.

ЧТО ДАННЫЕ ВЫДЕРЖИВАЮТ, И НИ СЛОВОМ БОЛЬШЕ
  · Это УЛИЦА, а не дом: deals.address уровня улицы, номер дома у 2.7% сделок.
    В схеме намеренно НЕТ координат окна и констант проекции — точку дома по
    ней нельзя поставить даже случайно. Это замок, а не забывчивость.
  · Зданий нет: cad_buildings — 18 307 контуров на город, в плотном центре 51
    здание на радиус 450 м, где их в разы больше. Нарисованная застройка
    заявляла бы полноту, которой в данных нет.
  · Улицы — фильтрованная выгрузка «источников шума»: именованные покрыты
    хорошо, дворовые и служебные проезды отсутствуют.
  · Улица сматчилась у 550 названий из 654 — 31 410 сделок из 34 021 (92.3%).
    Остальным street_scheme = NULL, и это штатно: фронт показывает район,
    который для этого и оставлен.
  · Перекрёсток («Челюскинцев/Шейнкмана») берём первой улицей: таких адресов
    три на 34 021 сделку, и обе улицы одинаково верны на уровне улицы.
  · Наличие схемы НА ОТБОР СТРОК НЕ ВЛИЯЕТ — то же правило, что запрещает
    отбор по величине ошибки: иначе витрина показывала бы не работу оценщика,
    а те 92% адресов, что удобно легли на OSM.

Тесты (каждый сломан вручную и покраснел): нормализация на реальных адресах
включая ё/е и «8 Марта»; недоступная вьюха и упавший запрос дают None, а не
исключение; схема не раздувается — потолок в байтах на реальной плотности плюс
прямая проверка округления до 0.1.
2026-08-29 23:25:44 +05:00

214 lines
10 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.

"""Схема улицы для витрины лэндинга: нормализация, отказ и размер.
Каждый тест здесь сломан вручную перед коммитом и покраснел — иначе он не
проверка, а декорация. Что именно ловится:
* нормализация названий на РЕАЛЬНЫХ строках `deals.address` и OSM;
* несматчившаяся улица не роняет пересчёт (штатные 8%);
* схема не раздувается: без округления координат она вырастает в разы.
"""
from __future__ import annotations
import contextlib
import json
import re
import pytest
from app.services import street_scheme as ss
# ── Нормализация ────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
("raw", "expected"),
[
# Реальные значения deals.address (прод, ЕКБ).
("Екатеринбург, Краснолесья", "краснолесья"),
("Екатеринбург, 8 Марта", "8 марта"),
("Екатеринбург, 22 Партсъезда", "22 партсъезда"),
("Екатеринбург, Академика Сахарова", "академика сахарова"),
# Перекрёсток: берём ПЕРВУЮ улицу (обоснование — в докстринге функции).
("Екатеринбург, Челюскинцев/Шейнкмана", "челюскинцев"),
("Екатеринбург, Шаумяна/Чкалова", "шаумяна"),
("Екатеринбург, пр-кт Космонавтов/ Ильича", "космонавтов"),
# Реальные значения name в osm_noise_sources_ekb.
("улица Красных Командиров", "красных командиров"),
("Амбулаторная улица", "амбулаторная"),
("проспект Космонавтов", "космонавтов"),
("Сибирский тракт", "сибирский"),
# ё → е: адрес и OSM пишут по-разному, улица одна.
("Екатеринбург, Королёва", "королева"),
("улица Королева", "королева"),
# Пусто — значит пусто, а не выдуманная улица.
(None, None),
("", None),
("Екатеринбург, улица", None),
],
)
def test_normalize_street(raw: str | None, expected: str | None) -> None:
assert ss.normalize_street(raw) == expected
def test_normalize_matches_address_to_osm_name() -> None:
"""Смысл нормализации: адрес сделки и название OSM сходятся в один ключ."""
assert ss.normalize_street("Екатеринбург, Краснолесья") == ss.normalize_street(
"улица Краснолесья"
)
assert ss.normalize_street("Екатеринбург, Королёва") == ss.normalize_street("улица Королева")
# ── Тестовый двойник сессии ─────────────────────────────────────────────────
class _Result:
def __init__(self, rows: list) -> None:
self._rows = rows
def all(self) -> list:
return self._rows
def mappings(self) -> _Result:
return self
def first(self):
return self._rows[0] if self._rows else None
class _FakeDB:
"""Отвечает на три запроса модуля. `fail` — имитация недоступной вьюхи."""
def __init__(self, *, names: list[str], center=None, window=None, fail: bool = False) -> None:
self.names = names
self.center = center
self.window = window or []
self.fail = fail
self.calls = 0
@contextlib.contextmanager
def begin_nested(self):
yield
def execute(self, stmt, params=None):
self.calls += 1
if self.fail:
raise RuntimeError("permission denied for view v_tradein_osm_roads_ekb")
sql = str(stmt)
if "DISTINCT name" in sql:
return _Result([(n,) for n in self.names])
if "ST_ClosestPoint" in sql:
return _Result([self.center] if self.center else [])
return _Result(self.window)
def _line(n: int, lon0: float = 60.53, lat0: float = 56.78) -> str:
pts = [[round(lon0 + i * 0.0002, 6), round(lat0 + i * 0.0001, 6)] for i in range(n)]
return json.dumps({"type": "LineString", "coordinates": pts})
# ── Несматчившаяся улица — штатный случай, а не авария ──────────────────────
def test_unmatched_street_returns_none_without_touching_db() -> None:
db = _FakeDB(names=["улица Краснолесья"])
index = ss.load_street_index(db)
before = db.calls
assert ss.build_street_scheme(db, index, "Екатеринбург, Такой Улицы Нет") is None
# Ни одного лишнего запроса: не сматчилось — не ходим в чужую базу.
assert db.calls == before
def test_missing_address_returns_none() -> None:
db = _FakeDB(names=["улица Краснолесья"])
index = ss.load_street_index(db)
assert ss.build_street_scheme(db, index, None) is None
def test_unavailable_view_gives_empty_index_not_exception() -> None:
"""Грант на той стороне терялся уже трижды. Пересчёт обязан это пережить."""
db = _FakeDB(names=[], fail=True)
index = ss.load_street_index(db)
assert index.by_norm == {}
assert ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья") is None
def test_query_failure_gives_none_not_exception() -> None:
class _FailOnSecond(_FakeDB):
def execute(self, stmt, params=None):
if "DISTINCT name" not in str(stmt):
raise RuntimeError("FDW connection refused")
return super().execute(stmt, params)
db = _FailOnSecond(names=["улица Краснолесья"])
index = ss.load_street_index(db)
assert ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья") is None
# ── Форма схемы и её размер ─────────────────────────────────────────────────
def _scheme_with(rows: list[dict]) -> dict:
db = _FakeDB(
names=["улица Краснолесья"],
center={"lon": 60.537283, "lat": 56.784694},
window=rows,
)
index = ss.load_street_index(db)
scheme = ss.build_street_scheme(db, index, "Екатеринбург, Краснолесья")
assert scheme is not None
return scheme
def test_scheme_shape() -> None:
scheme = _scheme_with(
[
{"name": "улица Краснолесья", "road_class": "tertiary", "gj": _line(5)},
{"name": "улица Чкалова", "road_class": "primary", "gj": _line(4, 60.534)},
{"name": "Патрушиха", "road_class": "river", "gj": _line(3, 60.535)},
]
)
assert scheme["street"] == "улица Краснолесья"
assert scheme["w"] == scheme["h"] == ss.VIEWBOX
assert len(scheme["target"]) == 1 and scheme["target"][0].startswith("M")
assert [r["c"] for r in scheme["roads"]] == ["primary"]
assert len(scheme["water"]) == 1
assert [label["t"] for label in scheme["labels"]] == ["улица Чкалова"]
# Ни координат окна, ни констант проекции: по схеме нельзя поставить дом.
assert not {"minLon", "maxLat", "k", "scale", "lat", "lon"} & set(scheme)
def _dense_window() -> list[dict]:
"""Плотность как в реальном окне (замер 2026-08-29 по «улица 8 Марта» и
«улица Краснолесья»): 56-58 отрезков в кадре, 114-118 точек после упрощения
на 3 м, то есть 2-2.7 точки на отрезок. Берём 3 — чуть плотнее замера."""
rows = [
{"name": "улица Краснолесья", "road_class": "tertiary", "gj": _line(3, 60.537 + i * 0.0002)}
for i in range(16)
]
rows += [
{
"name": f"улица №{i}",
"road_class": "residential",
"gj": _line(3, 60.5340 + i * 0.0004, 56.7820 + i * 0.0002),
}
for i in range(40)
]
return rows
def test_scheme_stays_small() -> None:
"""Порядок величины — 4 КБ. Замер на проде 2026-08-29: 2.8-2.9 КБ.
Сломать: убрать `round(..., 1)` в `path()` — координаты станут длинными
float'ами, и схема на том же окне перевалит за потолок.
"""
scheme = _scheme_with(_dense_window())
size = len(json.dumps(scheme, ensure_ascii=False, separators=(",", ":")).encode())
assert size < 4400, f"схема раздулась до {size} байт"
assert len(scheme["labels"]) == ss.MAX_LABELS
def test_coordinates_carry_one_decimal() -> None:
"""Прямая проверка того, чем держится размер: одна цифра после точки.
Потолок в байтах ловит раздутие только на той плотности, что в тесте; этот
ассерт ловит саму потерю округления на любой.
"""
scheme = _scheme_with(_dense_window())
blob = " ".join(scheme["target"] + [r["d"] for r in scheme["roads"]])
assert not re.search(r"\.\d\d", blob), "координаты перестали округляться до 0.1"
for label in scheme["labels"]:
assert not re.search(r"\.\d\d", f"{label['x']} {label['y']}")