All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m5s
CI / backend-tests (pull_request) Successful in 15m6s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
developer_attribution, connection_capacity_lookup (gas/heat helpers), competitors, saturation, supply_layers, and pat_lookup swallowed DB errors without begin_nested()/rollback(), leaving the shared analyze-request Session aborted for every subsequent db.execute (silently breaking persist_analysis_run and the forecast enqueue). zone_regulation's backfill_ekb_zone_regulations gets a plain rollback() since it only ever runs on its own owned Celery-task session. Refs #2464 (cluster A session-poisoning).
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""Тесты 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)
|