Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
521 lines
23 KiB
Python
521 lines
23 KiB
Python
"""Тесты для ekburg_permits.py (Issue #105).
|
||
|
||
Использует mock httpx + синтетические данные xlsx (openpyxl в памяти).
|
||
Не требует реального PostgreSQL или сетевого доступа.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
from io import BytesIO
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
import httpx
|
||
import pytest
|
||
from openpyxl import Workbook
|
||
|
||
from app.services.scrapers.ekburg_permits import (
|
||
EKBURG_PERMITS_URLS,
|
||
EkburgPermitsClient,
|
||
_clean_inn,
|
||
_detect_permit_type,
|
||
_to_date,
|
||
_to_float,
|
||
_to_str,
|
||
msk66_to_wgs84,
|
||
)
|
||
|
||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_xlsx_bytes(sheets: dict[str, list[tuple[Any, ...]]]) -> bytes:
|
||
"""Создать xlsx в памяти с заданными листами и строками."""
|
||
wb = Workbook()
|
||
wb.remove(wb.active) # удалить дефолтный лист
|
||
for sheet_name, rows in sheets.items():
|
||
ws = wb.create_sheet(sheet_name)
|
||
for row in rows:
|
||
ws.append(list(row))
|
||
buf = BytesIO()
|
||
wb.save(buf)
|
||
return buf.getvalue()
|
||
|
||
|
||
def _rns_sheet_rows() -> list[tuple[Any, ...]]:
|
||
"""Синтетические строки для листа РНС (Form 3).
|
||
|
||
Воспроизводит структуру 2024-2026: header row 4, данные с row 7.
|
||
"""
|
||
return [
|
||
# row 1: пусто
|
||
(None,) * 14,
|
||
# row 2: заголовок таблицы
|
||
(
|
||
"Таблица 3. Реестр выданных разрешений на строительство "
|
||
"объектов капитального строительства",
|
||
)
|
||
+ (None,) * 13,
|
||
# row 3: пусто
|
||
(None,) * 14,
|
||
# row 4: заголовки колонок
|
||
(
|
||
"Наименование застройщика",
|
||
"ИНН",
|
||
"Адрес застройщика",
|
||
"Тип строительного объекта1",
|
||
"Наименование объекта КС",
|
||
"Кадастровый номер ЗУ",
|
||
"Координаты X",
|
||
None,
|
||
"Адрес объекта",
|
||
"Реквизиты разрешения (номер)",
|
||
None,
|
||
"Дата окончания",
|
||
"Общая площадь, м2",
|
||
"Площадь жилых помещений по проекту, м2",
|
||
),
|
||
# row 5: подзаголовки
|
||
(None,) * 7 + ("X", "Y") + (None,) * 3 + ("номер", "дата") + (None,) * 2,
|
||
# row 6: нумерация столбцов
|
||
tuple(range(1, 15)),
|
||
# row 7: первая строка данных (МКД — ООО "Тест")
|
||
(
|
||
'ООО "Специализированный застройщик "Тест"',
|
||
6685000001,
|
||
"620000, г. Екатеринбург, ул. Тестовая, д. 1",
|
||
"многоквартирные жилые дома;",
|
||
"Многоквартирный жилой дом (№ 1 по ПЗУ)",
|
||
"66:41:0101001:123",
|
||
"1530000.0000",
|
||
"380000.0000",
|
||
"Свердловская обл., г. Екатеринбург, ул. Тестовая",
|
||
"66-41-99-2024",
|
||
datetime(2024, 3, 15),
|
||
datetime(2027, 3, 15),
|
||
12345.67,
|
||
8000.0,
|
||
),
|
||
# row 8: строка без permit_number (должна быть пропущена)
|
||
(
|
||
"ООО «Продолжение»",
|
||
6685000002,
|
||
"620001, г. Екатеринбург",
|
||
"производственные здания;",
|
||
"Производственный корпус",
|
||
"66:41:0101002:456",
|
||
"1531000.0000",
|
||
"381000.0000",
|
||
"Свердловская обл., г. Екатеринбург, ул. Другая",
|
||
None, # no permit_number → skip
|
||
datetime(2024, 5, 20),
|
||
datetime(2026, 5, 20),
|
||
500.0,
|
||
None,
|
||
),
|
||
# row 9: empty row (all None) — should be skipped
|
||
(None,) * 14,
|
||
]
|
||
|
||
|
||
def _rve_sheet_rows() -> list[tuple[Any, ...]]:
|
||
"""Синтетические строки для листа РВЭ (Form 4)."""
|
||
return [
|
||
(None,) * 19,
|
||
("Таблица 4. Реестр выданных разрешений на ввод в эксплуатацию",) + (None,) * 18,
|
||
(None,) * 19,
|
||
# row 4: заголовки
|
||
(
|
||
"Наименование застройщика",
|
||
"ИНН",
|
||
"Адрес застройщика",
|
||
"Тип строительного объекта1",
|
||
"Наименование объекта КС",
|
||
"Кадастровый номер ЗУ",
|
||
"Координаты X",
|
||
None,
|
||
"Адрес объекта",
|
||
"Реквизиты РНС (номер)",
|
||
None,
|
||
"Дата окончания РНС",
|
||
"Общая площадь, м2",
|
||
"Площадь жилых по проекту, м2",
|
||
"Площадь жилых фактически, м2",
|
||
"Реквизиты РВЭ (номер)",
|
||
None,
|
||
"Введённые мощности",
|
||
None,
|
||
),
|
||
(None,) * 7 + ("X", "Y") + (None,) * 5 + ("номер", "дата") + (None,) * 4,
|
||
tuple(range(1, 19)),
|
||
# row 7: первая строка данных
|
||
(
|
||
'ООО "Застройщик Ввода"',
|
||
6658000002,
|
||
"620100, г. Екатеринбург, ул. Вводная, д. 2",
|
||
"многоквартирные жилые дома;",
|
||
"Жилой дом (1 очередь) (№ 1 по ПЗУ)",
|
||
"66:41:0303001:789",
|
||
"1535000.0000",
|
||
"385000.0000",
|
||
"Свердловская обл., г. Екатеринбург, ул. Вводная",
|
||
"66-41-50-2022",
|
||
datetime(2022, 10, 1),
|
||
datetime(2026, 1, 1),
|
||
30000.0,
|
||
20000.0,
|
||
19800.5, # living_area_fact
|
||
"66-41-5-2026",
|
||
datetime(2026, 1, 13),
|
||
None,
|
||
None,
|
||
),
|
||
]
|
||
|
||
|
||
# ── unit tests: helpers ───────────────────────────────────────────────────────
|
||
|
||
|
||
class TestHelpers:
|
||
def test_to_date_datetime(self) -> None:
|
||
dt = datetime(2024, 3, 15, 0, 0)
|
||
assert _to_date(dt) == date(2024, 3, 15)
|
||
|
||
def test_to_date_string_dot(self) -> None:
|
||
assert _to_date("15.03.2024") == date(2024, 3, 15)
|
||
|
||
def test_to_date_none(self) -> None:
|
||
assert _to_date(None) is None
|
||
|
||
def test_to_date_invalid(self) -> None:
|
||
assert _to_date("not-a-date") is None
|
||
|
||
def test_to_float_comma(self) -> None:
|
||
assert _to_float("12345,67") == pytest.approx(12345.67)
|
||
|
||
def test_to_float_dash(self) -> None:
|
||
assert _to_float("-") is None
|
||
|
||
def test_to_float_int(self) -> None:
|
||
assert _to_float(1490) == pytest.approx(1490.0)
|
||
|
||
def test_to_str_nbsp(self) -> None:
|
||
assert _to_str("г.\xa0Екатеринбург") == "г. Екатеринбург"
|
||
|
||
def test_to_str_none(self) -> None:
|
||
assert _to_str(None) is None
|
||
|
||
def test_clean_inn_int(self) -> None:
|
||
assert _clean_inn(6685180480) == "6685180480"
|
||
|
||
def test_clean_inn_float(self) -> None:
|
||
# openpyxl иногда выдаёт float для длинных чисел
|
||
assert _clean_inn(6685180480.0) == "6685180480"
|
||
|
||
def test_clean_inn_string(self) -> None:
|
||
assert _clean_inn("6685180480") == "6685180480"
|
||
|
||
def test_clean_inn_none(self) -> None:
|
||
assert _clean_inn(None) is None
|
||
|
||
def test_clean_inn_short(self) -> None:
|
||
# 9 цифр — не ИНН
|
||
assert _clean_inn(123456789) is None
|
||
|
||
def test_detect_permit_type_rns(self) -> None:
|
||
assert _detect_permit_type("реестр разрешений на строительс") == "RNS"
|
||
|
||
def test_detect_permit_type_rve(self) -> None:
|
||
assert _detect_permit_type("реестр разрешений на ввод") == "RVE"
|
||
|
||
def test_detect_permit_type_unknown(self) -> None:
|
||
assert _detect_permit_type("Справочник") is None
|
||
|
||
|
||
# ── unit tests: xlsx parsing ──────────────────────────────────────────────────
|
||
|
||
|
||
class TestParseXlsx:
|
||
def _make_client(self) -> EkburgPermitsClient:
|
||
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
|
||
# Создаём mock httpx.Client чтобы не открывать реальные соединения
|
||
client._client = MagicMock()
|
||
return client
|
||
|
||
def test_parse_rns_yields_permit_row(self) -> None:
|
||
content = _make_xlsx_bytes(
|
||
{
|
||
"реестр разрешений на строительс": _rns_sheet_rows(),
|
||
"Справочник": [("многоквартирные жилые дома;",)],
|
||
}
|
||
)
|
||
client = self._make_client()
|
||
rows = list(client.parse_xlsx(content, 2024, EKBURG_PERMITS_URLS[2024]))
|
||
|
||
# Должна быть ровно одна строка (вторая без permit_number пропущена)
|
||
assert len(rows) == 1
|
||
row = rows[0]
|
||
assert row.permit_type == "RNS"
|
||
assert row.permit_number == "66-41-99-2024"
|
||
assert row.issue_date == date(2024, 3, 15)
|
||
assert row.expiry_date == date(2027, 3, 15)
|
||
assert row.developer_inn == "6685000001"
|
||
assert row.object_type == "многоквартирные жилые дома;"
|
||
assert row.total_area_sqm == pytest.approx(12345.67)
|
||
assert row.living_area_sqm == pytest.approx(8000.0)
|
||
assert row.source_year == 2024
|
||
# Тестовые координаты X=1530000, Y=380000 — должны попасть в ЕКБ-регион
|
||
assert row.geocoded_lat is not None
|
||
assert row.geocoded_lon is not None
|
||
assert 56.3 < row.geocoded_lat < 57.5
|
||
assert 59.5 < row.geocoded_lon < 61.5
|
||
|
||
def test_parse_rve_yields_permit_row_with_rve_fields(self) -> None:
|
||
content = _make_xlsx_bytes({"реестр разрешений на ввод": _rve_sheet_rows()})
|
||
client = self._make_client()
|
||
rows = list(client.parse_xlsx(content, 2026, EKBURG_PERMITS_URLS[2026]))
|
||
|
||
assert len(rows) == 1
|
||
row = rows[0]
|
||
assert row.permit_type == "RVE"
|
||
assert row.permit_number == "66-41-50-2022"
|
||
assert row.living_area_fact_sqm == pytest.approx(19800.5)
|
||
assert row.rve_number == "66-41-5-2026"
|
||
assert row.rve_date == date(2026, 1, 13)
|
||
|
||
def test_parse_skips_справочник_sheet(self) -> None:
|
||
content = _make_xlsx_bytes(
|
||
{
|
||
"Справочник": [("многоквартирные жилые дома;",)],
|
||
"Лист1": [(None,)],
|
||
}
|
||
)
|
||
client = self._make_client()
|
||
rows = list(client.parse_xlsx(content, 2026, EKBURG_PERMITS_URLS[2026]))
|
||
assert rows == []
|
||
|
||
def test_raw_row_stored(self) -> None:
|
||
content = _make_xlsx_bytes({"реестр разрешений на строительс": _rns_sheet_rows()})
|
||
client = self._make_client()
|
||
rows = list(client.parse_xlsx(content, 2024, EKBURG_PERMITS_URLS[2024]))
|
||
assert len(rows) == 1
|
||
# raw_row должен содержать dict с string keys
|
||
assert isinstance(rows[0].raw_row, dict)
|
||
assert "9" in rows[0].raw_row # колонка 9 = permit_number
|
||
|
||
|
||
# ── unit tests: download_xlsx ─────────────────────────────────────────────────
|
||
|
||
|
||
class TestDownloadXlsx:
|
||
def _client_with_mock_http(self, mock_response: MagicMock) -> EkburgPermitsClient:
|
||
"""Создать EkburgPermitsClient с замоканным внутренним httpx.Client."""
|
||
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
|
||
mock_http = MagicMock()
|
||
mock_http.get.return_value = mock_response
|
||
client._client = mock_http
|
||
return client
|
||
|
||
def test_download_returns_bytes(self) -> None:
|
||
fake_content = b"PK\x03\x04fake_xlsx_content"
|
||
mock_response = MagicMock()
|
||
mock_response.content = fake_content
|
||
mock_response.raise_for_status = MagicMock()
|
||
|
||
client = self._client_with_mock_http(mock_response)
|
||
result = client.download_xlsx(2026)
|
||
|
||
assert result == fake_content
|
||
|
||
def test_download_raises_for_unknown_year(self) -> None:
|
||
client = EkburgPermitsClient.__new__(EkburgPermitsClient)
|
||
client._client = MagicMock()
|
||
with pytest.raises(ValueError, match="year=9999"):
|
||
client.download_xlsx(9999)
|
||
|
||
def test_download_raises_on_http_error(self) -> None:
|
||
mock_response = MagicMock()
|
||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||
"404", request=MagicMock(), response=MagicMock()
|
||
)
|
||
|
||
client = self._client_with_mock_http(mock_response)
|
||
with pytest.raises(httpx.HTTPStatusError):
|
||
client.download_xlsx(2026)
|
||
|
||
|
||
# ── SSL verification test (Issue #242) ───────────────────────────────────────
|
||
|
||
|
||
class TestSslConfiguration:
|
||
def test_client_uses_verify_false(self) -> None:
|
||
"""EkburgPermitsClient должен создавать httpx.Client с verify=False.
|
||
|
||
екатеринбург.рф использует CA Минцифры РФ — не в certifi bundle.
|
||
Issue #242: SSL: CERTIFICATE_VERIFY_FAILED для всех 5 годов.
|
||
"""
|
||
with EkburgPermitsClient() as client:
|
||
# httpx.Client хранит ssl_context; verify=False создаёт unverified ctx
|
||
assert client._client._transport is not None
|
||
# Проверяем через атрибут _client напрямую — он должен быть httpx.Client
|
||
assert isinstance(client._client, httpx.Client)
|
||
|
||
|
||
# ── unit tests: msk66_to_wgs84 ───────────────────────────────────────────────
|
||
|
||
# Параметры допуска для cad_parcels cross-check (≈50м по Хаверсину)
|
||
# На широте ЕКБ (~56.9°): 1° широты ≈ 111320м, 1° долготы ≈ 60730м
|
||
# 50м ÷ 111320 ≈ 0.00045° по широте; 50м ÷ 60730 ≈ 0.00082° по долготе
|
||
_TOL_LAT_DEG = 0.00045 # ~50м по широте
|
||
_TOL_LON_DEG = 0.00082 # ~50м по долготе
|
||
|
||
|
||
class TestMsk66ToWgs84:
|
||
"""Тесты трансформации МСК-66 → WGS84.
|
||
|
||
Реальные данные из ekburg_construction_permits (сверены с cad_parcels ground-truth).
|
||
Калиброванный proj4: x_0=1496944, y_0=-5911077
|
||
— avg_err≈14м, median≈7м, p95≈58м на 738 точках с кадастровым номером.
|
||
|
||
Точки для cross-check получены из cad_parcels.geom (ST_Centroid) и проверены SQL:
|
||
X=1534814.7997, Y=394813.2001 → lon≈60.619637, lat≈56.871948
|
||
(cad 66:41:0110016:1046, погрешность <50м от центроида участка)
|
||
X=1544026.7997, Y=381585.4401 → lon≈60.768255, lat≈56.752330
|
||
(cad 66:41:0503018:248, аэропорт Кольцово)
|
||
X=1526550.1397, Y=387129.3801 → lon≈60.483224, lat≈56.803547
|
||
(cad 66:41:0306057:89, западная часть ЕКБ)
|
||
X=1531655.2797, Y=398430.2401 → lon≈60.568320, lat≈56.904674
|
||
(cad 66:41:0106051:10, ул. Республиканская 1а; err_m=12)
|
||
"""
|
||
|
||
def test_kosmonavtov_cad_crosscheck(self) -> None:
|
||
"""X=1534814.7997/Y=394813.2001 — сверка с centroid cad_parcels 66:41:0110016:1046.
|
||
|
||
Ожидаемые координаты получены из PostGIS: lon≈60.619637, lat≈56.871948.
|
||
Допуск ±0.00082° по долготе (~50м), ±0.00045° по широте (~50м).
|
||
"""
|
||
result = msk66_to_wgs84("1534814.7997", "394813.2001")
|
||
assert result is not None
|
||
lon, lat = result
|
||
assert (
|
||
abs(lon - 60.619637) < _TOL_LON_DEG
|
||
), f"lon={lon:.6f} далеко от эталонного 60.619637 (Δ={abs(lon - 60.619637):.6f}°)"
|
||
assert (
|
||
abs(lat - 56.871948) < _TOL_LAT_DEG
|
||
), f"lat={lat:.6f} далеко от эталонного 56.871948 (Δ={abs(lat - 56.871948):.6f}°)"
|
||
|
||
def test_koltsovo_cad_crosscheck(self) -> None:
|
||
"""X=1544026.7997/Y=381585.4401 — сверка с centroid cad_parcels 66:41:0503018:248.
|
||
|
||
Ожидаемые координаты: lon≈60.768255, lat≈56.752330 (аэропорт Кольцово).
|
||
"""
|
||
result = msk66_to_wgs84("1544026.7997", "381585.4401")
|
||
assert result is not None
|
||
lon, lat = result
|
||
assert (
|
||
abs(lon - 60.768255) < _TOL_LON_DEG
|
||
), f"lon={lon:.6f} далеко от эталонного 60.768255 (Δ={abs(lon - 60.768255):.6f}°)"
|
||
assert (
|
||
abs(lat - 56.752330) < _TOL_LAT_DEG
|
||
), f"lat={lat:.6f} далеко от эталонного 56.752330 (Δ={abs(lat - 56.752330):.6f}°)"
|
||
|
||
def test_smorodinovaya_cad_crosscheck(self) -> None:
|
||
"""X=1526550.1397/Y=387129.3801 — сверка с centroid cad_parcels 66:41:0306057:89.
|
||
|
||
Ожидаемые координаты: lon≈60.483224, lat≈56.803547 (западная часть ЕКБ).
|
||
"""
|
||
result = msk66_to_wgs84("1526550.1397", "387129.3801")
|
||
assert result is not None
|
||
lon, lat = result
|
||
assert (
|
||
abs(lon - 60.483224) < _TOL_LON_DEG
|
||
), f"lon={lon:.6f} далеко от эталонного 60.483224 (Δ={abs(lon - 60.483224):.6f}°)"
|
||
assert (
|
||
abs(lat - 56.803547) < _TOL_LAT_DEG
|
||
), f"lat={lat:.6f} далеко от эталонного 56.803547 (Δ={abs(lat - 56.803547):.6f}°)"
|
||
|
||
def test_respublikanskaya_cad_crosscheck(self) -> None:
|
||
"""X=1531655.2797/Y=398430.2401 — ул. Республиканская 1а, cad 66:41:0106051:10.
|
||
|
||
Ожидаемые координаты: lon≈60.568320, lat≈56.904674 (err_m=12 по cad_parcels).
|
||
Этот тест ловит регрессию easting-сдвига: при x_0=1500000 computed был бы ~60.519°.
|
||
"""
|
||
result = msk66_to_wgs84("1531655.2797", "398430.2401")
|
||
assert result is not None
|
||
lon, lat = result
|
||
assert (
|
||
abs(lon - 60.568320) < _TOL_LON_DEG
|
||
), f"lon={lon:.6f} далеко от эталонного 60.568320 (Δ={abs(lon - 60.568320):.6f}°)"
|
||
assert (
|
||
abs(lat - 56.904674) < _TOL_LAT_DEG
|
||
), f"lat={lat:.6f} далеко от эталонного 56.904674 (Δ={abs(lat - 56.904674):.6f}°)"
|
||
|
||
def test_kosmonavtov_in_ekb(self) -> None:
|
||
"""Пр. Космонавтов — попадает в ЕКБ-регион."""
|
||
result = msk66_to_wgs84("1534814.7997", "394813.2001")
|
||
assert result is not None
|
||
lon, lat = result
|
||
assert 56.5 < lat < 57.2, f"lat={lat} вне ЕКБ"
|
||
assert 60.0 < lon < 61.0, f"lon={lon} вне ЕКБ"
|
||
|
||
def test_koltsovo_in_ekb(self) -> None:
|
||
"""Район аэропорта Кольцово — попадает в ЕКБ-регион."""
|
||
result = msk66_to_wgs84("1544026.7997", "381585.4401")
|
||
assert result is not None
|
||
lon, lat = result
|
||
assert 56.5 < lat < 57.2, f"lat={lat} вне ЕКБ"
|
||
assert 60.3 < lon < 61.2, f"lon={lon} вне ЕКБ"
|
||
|
||
def test_smorodinovaya_in_ekb(self) -> None:
|
||
"""Ул. Смородиновая, западная часть ЕКБ."""
|
||
result = msk66_to_wgs84("1526550.1397", "387129.3801")
|
||
assert result is not None
|
||
lon, lat = result
|
||
assert 56.5 < lat < 57.2, f"lat={lat} вне ЕКБ"
|
||
assert 59.5 < lon < 61.0, f"lon={lon} вне ЕКБ"
|
||
|
||
def test_multi_point_cell_takes_first(self) -> None:
|
||
"""Если ячейка содержит несколько точек через пробел — берём первую."""
|
||
# Реальный пример: "1528834.9797 1528819.0197"
|
||
result = msk66_to_wgs84("1528834.9797 1528819.0197", "389190.2401 389136.0601")
|
||
assert result is not None
|
||
lon, lat = result
|
||
assert 56.3 < lat < 57.5
|
||
assert 59.5 < lon < 61.5
|
||
|
||
def test_comma_decimal_separator(self) -> None:
|
||
"""Запятая как десятичный разделитель (встречается в xlsx)."""
|
||
# "1534655,7197" — реальный случай
|
||
result = msk66_to_wgs84("1534655,7197", "387724.5201")
|
||
assert result is not None
|
||
lon, lat = result
|
||
assert 56.3 < lat < 57.5
|
||
assert 59.5 < lon < 61.5
|
||
|
||
def test_none_input_returns_none(self) -> None:
|
||
assert msk66_to_wgs84(None, None) is None
|
||
|
||
def test_empty_string_returns_none(self) -> None:
|
||
assert msk66_to_wgs84("", "") is None
|
||
|
||
def test_non_numeric_returns_none(self) -> None:
|
||
assert msk66_to_wgs84("нет данных", "нет данных") is None
|
||
|
||
def test_out_of_range_x_returns_none(self) -> None:
|
||
"""X вне диапазона МСК-66 (например, случайное число) → None."""
|
||
# X=500000 — это не МСК-66 (false_easting ~1500000, диапазон 1450000-1600000)
|
||
result = msk66_to_wgs84("500000.0", "380000.0")
|
||
assert result is None
|
||
|
||
def test_result_is_lon_lat_order(self) -> None:
|
||
"""Результат — (lon, lat), а не (lat, lon).
|
||
|
||
ЕКБ: lon ~60.5E, lat ~56.9N → lon > lat числово.
|
||
"""
|
||
result = msk66_to_wgs84("1534814.7997", "394813.2001")
|
||
assert result is not None
|
||
lon, lat = result
|
||
# Проверяем что первый элемент — долгота (60.x), второй — широта (56.x)
|
||
assert 56.0 < lat < 58.0, f"ожидали широту, получили {lat}"
|
||
assert 59.0 < lon < 62.0, f"ожидали долготу, получили {lon}"
|
||
assert lon > lat # lon ~60.6 > lat ~56.9
|