"""Тесты PAT-подзон (#1065 / #1067): - репроекция координат МСК-66 → WGS84 (проверка оси, bbox Кольцово) - построение MultiPolygon из секторов (внешние + дырки) - lookup graceful при отсутствии таблицы / WKT """ from __future__ import annotations import importlib import pathlib from contextlib import contextmanager from typing import Any import pytest from pyproj import CRS, Transformer from shapely.geometry import MultiPolygon from sqlalchemy.exc import OperationalError from app.services.site_finder.pat_lookup import parcel_pat_subzones from app.workers.tasks.pat_subzones_load import ( _build_subzone_geometry, _close_ring, _is_hole_sector, ) # ── Репроекция ────────────────────────────────────────────────────────────── _MSK66_PROJ4 = ( "+proj=tmerc +lat_0=0 +lon_0=60 +k=1" " +x_0=1496944 +y_0=-5911077" " +ellps=krass +towgs84=23.92,-141.27,-80.9,0,0.35,0.82,-0.12" " +units=m +no_defs" ) _TR = Transformer.from_crs( CRS.from_proj4(_MSK66_PROJ4), CRS.from_epsg(4326), always_xy=True, ) # Известные точки из subzone 1 sector 1 (koltsovo_367p_subzones.json) # point=[X=northing, Y=easting]; верный порядок: transform(easting=Y, northing=X) _KOLTSOVO_POINTS = [ ([381550.81, 1547094.51], (60.818, 56.751)), # первая точка сектора 1 ([381553.39, 1547585.62], (60.826, 56.752)), # третья ([381562.21, 1547603.09], (60.827, 56.752)), # четвёртая ] _KOLTSOVO_LON_MIN = 60.5 _KOLTSOVO_LON_MAX = 61.2 _KOLTSOVO_LAT_MIN = 56.5 _KOLTSOVO_LAT_MAX = 57.0 @pytest.mark.parametrize("point,expected", _KOLTSOVO_POINTS) def test_reproject_msk66_to_wgs84_in_koltsovo( point: list[float], expected: tuple[float, float] ) -> None: """Репроекция МСК-66 [northing, easting] → WGS84 даёт координаты в bbox Кольцово.""" northing, easting = point[0], point[1] lon, lat = _TR.transform(easting, northing) # always_xy: x=easting, y=northing # Грубый bbox Кольцово msg_lon = f"lon={lon:.4f} вне bbox Кольцово [{_KOLTSOVO_LON_MIN}, {_KOLTSOVO_LON_MAX}]" assert _KOLTSOVO_LON_MIN <= lon <= _KOLTSOVO_LON_MAX, msg_lon msg_lat = f"lat={lat:.4f} вне bbox Кольцово [{_KOLTSOVO_LAT_MIN}, {_KOLTSOVO_LAT_MAX}]" assert _KOLTSOVO_LAT_MIN <= lat <= _KOLTSOVO_LAT_MAX, msg_lat # Проверяем приблизительное совпадение с ожидаемым (±0.005°) exp_lon, exp_lat = expected assert abs(lon - exp_lon) < 0.005, f"lon расхождение: got {lon:.4f} exp {exp_lon}" assert abs(lat - exp_lat) < 0.005, f"lat расхождение: got {lat:.4f} exp {exp_lat}" def test_axis_swap_gives_wrong_result() -> None: """Неверный порядок осей (x=northing, y=easting) выдаёт координаты вне Кольцово.""" northing, easting = 381550.81, 1547094.51 # Неверный порядок: x=northing, y=easting lon_wrong, lat_wrong = _TR.transform(northing, easting) # Должно быть ВНЕ bbox Кольцово in_koltsovo = ( _KOLTSOVO_LON_MIN <= lon_wrong <= _KOLTSOVO_LON_MAX and _KOLTSOVO_LAT_MIN <= lat_wrong <= _KOLTSOVO_LAT_MAX ) msg = ( f"Неверный порядок осей дал координаты в Кольцово: lon={lon_wrong:.4f} lat={lat_wrong:.4f}" ) assert not in_koltsovo, msg # ── Построение геометрии ──────────────────────────────────────────────────── def _make_sector(label: str | None, pts: list[tuple[float, float]]) -> dict[str, Any]: """Вспомогательная фикстура: сектор с точками уже в WGS84-диапазоне. Используем точки в МСК-66 которые проецируются в WGS84 Кольцово. """ # Конвертируем WGS84-like точки в «псевдо МСК-66» для реального прогона через _build. # Проще передать реальные МСК-66 точки из JSON. return { "label": label, "points": [list(p) for p in pts], } # Набор точек в МСК-66 (northing, easting) образующих маленький треугольник около Кольцово _TRI_OUTER = [ [381500.0, 1547000.0], [381600.0, 1547000.0], [381600.0, 1547100.0], [381500.0, 1547000.0], # замкнуто ] _TRI_HOLE = [ [381520.0, 1547020.0], [381560.0, 1547020.0], [381560.0, 1547060.0], [381520.0, 1547020.0], ] def test_build_single_exterior_sector() -> None: """Один внешний сектор → валидный MultiPolygon.""" sectors = [{"label": "Сектор 1", "points": _TRI_OUTER}] geom = _build_subzone_geometry(sectors) assert isinstance(geom, MultiPolygon) assert not geom.is_empty assert geom.is_valid def test_build_exterior_with_hole() -> None: """Внешний сектор + внутренняя граница → Polygon с дыркой.""" sectors = [ {"label": "Сектор 1. Внешняя граница", "points": _TRI_OUTER}, {"label": "Сектор 1. Внутренняя граница 1", "points": _TRI_HOLE}, ] geom = _build_subzone_geometry(sectors) assert isinstance(geom, MultiPolygon) assert not geom.is_empty assert geom.is_valid # Площадь с дыркой должна быть меньше только внешней outer_only = _build_subzone_geometry([sectors[0]]) assert geom.area < outer_only.area def test_build_two_exterior_sectors() -> None: """Два внешних сектора → MultiPolygon из двух Polygon-ов.""" tri2 = [ [382000.0, 1548000.0], [382100.0, 1548000.0], [382100.0, 1548100.0], [382000.0, 1548000.0], ] sectors = [ {"label": "Сектор 1", "points": _TRI_OUTER}, {"label": "Сектор 2", "points": tri2}, ] geom = _build_subzone_geometry(sectors) assert isinstance(geom, MultiPolygon) assert not geom.is_empty assert geom.is_valid def test_build_empty_sector_skipped() -> None: """Сектор без точек не ломает построение.""" sectors = [ {"label": "Пустой", "points": []}, {"label": "Сектор 1", "points": _TRI_OUTER}, ] geom = _build_subzone_geometry(sectors) assert isinstance(geom, MultiPolygon) assert not geom.is_empty def test_is_hole_sector_detection() -> None: """Детекция внутренних секторов по label.""" assert _is_hole_sector("Сектор 1. Внутренняя граница 1") is True assert _is_hole_sector("Внутренняя граница") is True assert _is_hole_sector("internal boundary") is True assert _is_hole_sector("Исключённая территория") is True assert _is_hole_sector("Сектор 1. Внешняя граница") is False assert _is_hole_sector("Сектор 1") is False assert _is_hole_sector(None) is False def test_close_ring_adds_closing_point() -> None: """Незамкнутое кольцо дополняется копией первой точки.""" coords = [(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)] closed = _close_ring(coords) assert closed[-1] == closed[0] assert len(closed) == 4 def test_close_ring_already_closed() -> None: """Уже замкнутое кольцо не изменяется.""" coords = [(1.0, 2.0), (3.0, 4.0), (1.0, 2.0)] closed = _close_ring(coords) assert len(closed) == 3 # ── Lookup graceful ───────────────────────────────────────────────────────── class _Result: def __init__(self, rows: list[dict[str, Any]]) -> None: self._rows = rows def mappings(self) -> _Result: return self def all(self) -> list[dict[str, Any]]: return self._rows class _FakeDB: """begin_nested — реальный (не-swallow) CM: зеркалит SAVEPOINT-фикс #2464 cluster A finding 7 (parcel_pat_subzones); исключение из db.execute должно долетать до except в проде, не глушиться на выходе из ``with``.""" def __init__( self, rows: list[dict[str, Any]] | None = None, raise_exc: Exception | None = None, ) -> None: self._rows = rows or [] self._raise = raise_exc self.execute_calls = 0 @contextmanager def begin_nested(self): # type: ignore[no-untyped-def] yield def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result: self.execute_calls += 1 if self._raise is not None: raise self._raise return _Result(self._rows) _WKT = "POLYGON((60.8 56.7, 60.9 56.7, 60.9 56.8, 60.8 56.8, 60.8 56.7))" def test_pat_lookup_returns_subzones() -> None: rows = [ { "subzone_no": 2, "name": "Вторая подзона", "restriction": "Ограничения второй подзоны", "aerodrome": "Екатеринбург (Кольцово)", } ] result = parcel_pat_subzones(_FakeDB(rows), _WKT) assert len(result) == 1 assert result[0]["subzone_no"] == 2 assert result[0]["name"] == "Вторая подзона" assert result[0]["aerodrome"] == "Екатеринбург (Кольцово)" def test_pat_lookup_empty_when_no_wkt() -> None: result = parcel_pat_subzones(_FakeDB([{"subzone_no": 1}]), None) assert result == [] def test_pat_lookup_empty_when_no_intersections() -> None: result = parcel_pat_subzones(_FakeDB([]), _WKT) assert result == [] def test_pat_lookup_graceful_table_missing() -> None: """pat_subzones не задеплоена → пусто, без падения.""" db = _FakeDB(raise_exc=OperationalError("stmt", {}, Exception("relation does not exist"))) result = parcel_pat_subzones(db, _WKT) assert result == [] def test_pat_lookup_graceful_programming_error() -> None: """ProgrammingError (колонка не существует) → graceful.""" from sqlalchemy.exc import ProgrammingError db = _FakeDB(raise_exc=ProgrammingError("stmt", {}, Exception("column not found"))) result = parcel_pat_subzones(db, _WKT) assert result == [] def test_pat_lookup_db_error_leaves_session_usable_for_next_query() -> None: """#2464 cluster A finding 7: db shared с caller (analyze_parcel) — сбой здесь не должен отравлять session для persist_analysis_run и прочих db.execute дальше по /analyze. Симулируем: execute кидает ОДИН раз, затем следующий (не связанный) execute на ТОЙ ЖЕ session отрабатывает нормально.""" from sqlalchemy.exc import OperationalError db = _FakeDB(raise_exc=OperationalError("stmt", {}, Exception("relation does not exist"))) result = parcel_pat_subzones(db, _WKT) assert result == [] assert db.execute_calls == 1 # Сессия осталась usable: сбрасываем "raise" (имитируя, что savepoint откатился # и не поломала outer-транзакцию) и проверяем, что db.execute снова отрабатывает. db._raise = None db._rows = [{"subzone_no": 3, "name": "N", "restriction": "R", "aerodrome": "A"}] next_result = parcel_pat_subzones(db, _WKT) assert next_result == [{"subzone_no": 3, "name": "N", "restriction": "R", "aerodrome": "A"}] assert db.execute_calls == 2 # ── Резолв пути к JSON (#1150) ────────────────────────────────────────────── def test_json_path_candidates_non_empty() -> None: """_JSON_PATH_CANDIDATES содержит хотя бы один кандидат.""" from app.workers.tasks.pat_subzones_load import _JSON_PATH_CANDIDATES assert len(_JSON_PATH_CANDIDATES) >= 1 def test_json_path_candidates_end_with_expected_suffix() -> None: """Каждый кандидат заканчивается на data/pat/koltsovo_367p_subzones.json.""" import pathlib from app.workers.tasks.pat_subzones_load import _JSON_PATH_CANDIDATES expected_suffix = pathlib.Path("data") / "pat" / "koltsovo_367p_subzones.json" for candidate in _JSON_PATH_CANDIDATES: # последние 3 части пути совпадают с ожидаемыми assert ( candidate.parts[-3:] == expected_suffix.parts ), f"Кандидат {candidate} не заканчивается на {expected_suffix}" def test_resolve_json_path_finds_real_file() -> None: """_resolve_json_path() находит JSON-файл в репо (data/pat/ на уровне repo root).""" from app.workers.tasks.pat_subzones_load import _resolve_json_path resolved = _resolve_json_path() assert resolved.exists(), ( f"JSON-файл подзон ПАТ не найден по пути {resolved}. " "Убедитесь что data/pat/koltsovo_367p_subzones.json присутствует в репо." ) assert resolved.name == "koltsovo_367p_subzones.json" def test_resolve_json_path_env_override( monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """PAT_JSON_PATH env-переменная переопределяет путь к JSON.""" import app.workers.tasks.pat_subzones_load as mod # Создаём временный файл fake_json = tmp_path / "fake_subzones.json" fake_json.write_text("{}", encoding="utf-8") monkeypatch.setenv("PAT_JSON_PATH", str(fake_json)) # Перезагружаем модуль чтобы env-переменная была подхвачена на уровне модуля mod_reloaded = importlib.reload(mod) resolved = mod_reloaded._resolve_json_path() assert resolved == fake_json # Откат — перезагрузить без env monkeypatch.delenv("PAT_JSON_PATH", raising=False) importlib.reload(mod)