Карта в карточке игры показывала полигон района — единственную геометрию, до которой у 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.
214 lines
10 KiB
Python
214 lines
10 KiB
Python
"""Схема улицы для витрины лэндинга: нормализация, отказ и размер.
|
||
|
||
Каждый тест здесь сломан вручную перед коммитом и покраснел — иначе он не
|
||
проверка, а декорация. Что именно ловится:
|
||
* нормализация названий на РЕАЛЬНЫХ строках `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']}")
|