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