All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 14m22s
Открытых координат для 2012 городских ТП нет (разведка: WFS портал-тп.рф слой ТП не публикует; в xlsx ЕЭСК только номер+мощности) — потолок OSM ~119 точек (5.9%). Матч name_norm (чистый номер) ↔ OSM substation ref/name через Overpass (bbox ЕКБ, kumi.systems — overpass-api.de даёт 406); консервативная экстракция (ТП/РП-токен или голый ref, вольтаж мимо), дубль-номера → ambiguous-скип, same-point дедуп ~11м, bbox-sanity, UPDATE geom+geocode_status='osm' WHERE geom IS NULL (idempotent, SAVEPOINT). Dry-run дефолт + CLI --apply; Celery вт 05:45 МСК. Полное покрытие — только B2B-запрос реестра в ЕЭСК (у Anton).
286 lines
12 KiB
Python
286 lines
12 KiB
Python
"""Unit-тесты OSM-геокода городских ТП/РП ЕКБ (#2119 B2, финал).
|
||
|
||
Pure / mock-based — без реальной сети и БД. Покрывает:
|
||
- extract_tp_number: «РП-101»→101, «ТП 1591»→1591, голый ref='101'→101,
|
||
«110 кВ» БЕЗ ТП-токена/ref→None, traction-скип на уровне fetch.
|
||
- fetch_osm_substations: скип substation=traction (mock httpx.post).
|
||
- _index_osm_by_number: дубль-номер (разные координаты) → ambiguous, не матчится;
|
||
дубль-теги одной точки (way+node, те же координаты) → НЕ ambiguous.
|
||
- geocode_tp_from_osm: dry-run НЕ пишет; apply=True шлёт UPDATE с CAST-формами
|
||
и geom IS NULL-гейтом; sanity-bbox отсекает координату вне ЕКБ.
|
||
- Celery-регистрация таска sync_osm_tp_geocode + beat-строка.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from contextlib import contextmanager
|
||
from typing import Any
|
||
|
||
from app.services.site_finder import osm_tp_geocoder as og
|
||
|
||
# ── extract_tp_number ────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_extract_tp_number_rp_token() -> None:
|
||
"""«РП-101» в name → 101 (ТП-токен + число)."""
|
||
assert og.extract_tp_number({"name": "РП-101"}) == "101"
|
||
|
||
|
||
def test_extract_tp_number_tp_hyphen() -> None:
|
||
"""«ТП-180» → 180."""
|
||
assert og.extract_tp_number({"name": "ТП-180"}) == "180"
|
||
|
||
|
||
def test_extract_tp_number_tp_space() -> None:
|
||
"""«ТП 1591» (пробел-разделитель) → 1591."""
|
||
assert og.extract_tp_number({"name": "ТП 1591"}) == "1591"
|
||
|
||
|
||
def test_extract_tp_number_bare_ref() -> None:
|
||
"""Голый ref='101' (без ТП-токена) → 101 — оператор проставил ref явно."""
|
||
assert og.extract_tp_number({"ref": "101"}) == "101"
|
||
|
||
|
||
def test_extract_tp_number_ref_priority_over_name() -> None:
|
||
"""ref приоритетнее name: ref='42', name='ТП-999' → 42."""
|
||
assert og.extract_tp_number({"ref": "42", "name": "ТП-999"}) == "42"
|
||
|
||
|
||
def test_extract_tp_number_voltage_without_token_is_none() -> None:
|
||
"""«110 кВ» БЕЗ ТП-токена и без ref → None (это класс напряжения, не номер)."""
|
||
assert og.extract_tp_number({"name": "Подстанция 110 кВ"}) is None
|
||
assert og.extract_tp_number({"name": "110 кВ", "voltage": "110000"}) is None
|
||
|
||
|
||
def test_extract_tp_number_empty_tags_is_none() -> None:
|
||
"""Нет ни ref, ни name → None."""
|
||
assert og.extract_tp_number({}) is None
|
||
|
||
|
||
def test_extract_tp_number_latin_tp_token() -> None:
|
||
"""Латинский токен «TP-42» → 42."""
|
||
assert og.extract_tp_number({"name": "TP-42"}) == "42"
|
||
|
||
|
||
# ── fetch_osm_substations: traction-скип ─────────────────────────────────────
|
||
|
||
|
||
class _FakeResp:
|
||
def __init__(self, payload: dict) -> None:
|
||
self._payload = payload
|
||
|
||
def raise_for_status(self) -> None:
|
||
pass
|
||
|
||
def json(self) -> dict:
|
||
return self._payload
|
||
|
||
|
||
def test_fetch_skips_traction(monkeypatch: Any) -> None:
|
||
"""substation=traction (тяговые ж/д) отсеиваются; обычные ТП остаются."""
|
||
payload = {
|
||
"elements": [
|
||
{"type": "node", "id": 1, "lat": 56.8, "lon": 60.6, "tags": {"name": "ТП-1"}},
|
||
{
|
||
"type": "node",
|
||
"id": 2,
|
||
"lat": 56.8,
|
||
"lon": 60.6,
|
||
"tags": {"substation": "traction", "name": "Тяговая-2"},
|
||
},
|
||
]
|
||
}
|
||
|
||
def _fake_post(url: str, data: Any = None, timeout: Any = None) -> _FakeResp:
|
||
return _FakeResp(payload)
|
||
|
||
monkeypatch.setattr(og.httpx, "post", _fake_post)
|
||
kept = og.fetch_osm_substations()
|
||
assert len(kept) == 1
|
||
assert kept[0]["id"] == 1
|
||
|
||
|
||
# ── _index_osm_by_number: ambiguous ──────────────────────────────────────────
|
||
|
||
|
||
def _node(el_id: int, lat: float, lon: float, tags: dict) -> dict:
|
||
return {"type": "node", "id": el_id, "lat": lat, "lon": lon, "tags": tags}
|
||
|
||
|
||
def test_index_ambiguous_duplicate_number() -> None:
|
||
"""Один номер несут 2 фичи с РАЗНЫМИ координатами → ambiguous, из матча выпадает."""
|
||
elements = [
|
||
_node(1, 56.80, 60.60, {"name": "ТП-100"}),
|
||
_node(2, 56.85, 60.65, {"name": "ТП-100"}), # тот же номер, другая точка
|
||
_node(3, 56.82, 60.62, {"name": "ТП-200"}), # уникальный
|
||
]
|
||
by_number, ambiguous = og._index_osm_by_number(elements)
|
||
assert "100" in ambiguous
|
||
assert "100" not in by_number
|
||
assert "200" in by_number
|
||
|
||
|
||
def test_index_same_point_not_ambiguous() -> None:
|
||
"""Дубль-теги ОДНОЙ точки (way+node на тех же координатах) → НЕ ambiguous."""
|
||
elements = [
|
||
_node(1, 56.80, 60.60, {"name": "ТП-100"}),
|
||
{"type": "way", "id": 2, "center": {"lat": 56.80, "lon": 60.60}, "tags": {"ref": "100"}},
|
||
]
|
||
by_number, ambiguous = og._index_osm_by_number(elements)
|
||
assert "100" not in ambiguous
|
||
assert by_number["100"] == (56.80, 60.60)
|
||
|
||
|
||
def test_index_out_of_bbox_skipped() -> None:
|
||
"""Координата вне bbox ЕКБ (sanity-гейт) → не индексируется."""
|
||
elements = [_node(1, 55.75, 37.61, {"name": "ТП-300"})] # Москва
|
||
by_number, ambiguous = og._index_osm_by_number(elements)
|
||
assert by_number == {}
|
||
assert ambiguous == set()
|
||
|
||
|
||
def test_index_way_center_coords() -> None:
|
||
"""way без lat/lon, но с center → берём center."""
|
||
elements = [
|
||
{"type": "way", "id": 9, "center": {"lat": 56.83, "lon": 60.63}, "tags": {"ref": "555"}}
|
||
]
|
||
by_number, _ = og._index_osm_by_number(elements)
|
||
assert by_number["555"] == (56.83, 60.63)
|
||
|
||
|
||
# ── Fake DB session (execute + begin_nested), как в test_eesk_reserve_loader ──
|
||
|
||
|
||
class _FakeResult:
|
||
def __init__(self, rows: list[Any] | None = None, rowcount: int = 1) -> None:
|
||
self._rows = rows or []
|
||
self.rowcount = rowcount
|
||
|
||
def all(self) -> list[Any]:
|
||
return self._rows
|
||
|
||
|
||
class _FakeSession:
|
||
"""Мини-заглушка Session: собирает execute-вызовы, поддерживает begin_nested."""
|
||
|
||
def __init__(self, candidate_norms: list[str], rowcount: int = 1) -> None:
|
||
self.calls: list[tuple[str, Any]] = []
|
||
self._candidates = [(n,) for n in candidate_norms]
|
||
self._rowcount = rowcount
|
||
self.committed = False
|
||
|
||
@contextmanager
|
||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||
yield
|
||
|
||
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
|
||
sql = str(stmt)
|
||
self.calls.append((sql, params))
|
||
if "SELECT" in sql and "power_tp_rp_reserves" in sql:
|
||
return _FakeResult(rows=self._candidates)
|
||
return _FakeResult(rowcount=self._rowcount)
|
||
|
||
def commit(self) -> None:
|
||
self.committed = True
|
||
|
||
def rollback(self) -> None:
|
||
pass
|
||
|
||
|
||
def _patch_fetch(monkeypatch: Any, elements: list[dict]) -> None:
|
||
monkeypatch.setattr(og, "fetch_osm_substations", lambda: elements)
|
||
|
||
|
||
# ── geocode_tp_from_osm ──────────────────────────────────────────────────────
|
||
|
||
|
||
def test_geocode_dry_run_does_not_write(monkeypatch: Any) -> None:
|
||
"""Dry-run (apply=False): matched считается, но UPDATE не шлётся и commit не зовётся."""
|
||
_patch_fetch(monkeypatch, [_node(1, 56.80, 60.60, {"name": "ТП-101"})])
|
||
db = _FakeSession(candidate_norms=["101"])
|
||
result = og.geocode_tp_from_osm(db=db, apply=False)
|
||
|
||
assert result["matched"] == 1
|
||
assert result["updated"] == 0
|
||
assert result["applied"] is False
|
||
assert not db.committed
|
||
# Только SELECT-кандидатов, никаких UPDATE.
|
||
assert all("UPDATE" not in sql for sql, _ in db.calls)
|
||
|
||
|
||
def test_geocode_apply_updates_with_cast(monkeypatch: Any) -> None:
|
||
"""apply=True: UPDATE шлётся с CAST-формами, geom IS NULL-гейтом, commit зовётся."""
|
||
_patch_fetch(monkeypatch, [_node(1, 56.80, 60.60, {"name": "ТП-101"})])
|
||
db = _FakeSession(candidate_norms=["101"], rowcount=1)
|
||
result = og.geocode_tp_from_osm(db=db, apply=True)
|
||
|
||
assert result["matched"] == 1
|
||
assert result["updated"] == 1
|
||
assert result["applied"] is True
|
||
assert db.committed
|
||
|
||
update_calls = [(sql, p) for sql, p in db.calls if "UPDATE" in sql]
|
||
assert len(update_calls) == 1
|
||
sql, params = update_calls[0]
|
||
# CAST-формы (psycopg3-канон), НИКОГДА :x::type.
|
||
assert "CAST(:lon AS float)" in sql
|
||
assert "CAST(:lat AS float)" in sql
|
||
assert "CAST(:name_norm AS text)" in sql
|
||
assert "::float" not in sql and "::text" not in sql
|
||
# Идемпотентность + скоуп ЕКБ.
|
||
assert "geom IS NULL" in sql
|
||
assert "geocode_status = 'osm'" in sql
|
||
assert "municipality = 'Екатеринбург'" in sql
|
||
assert params == {"name_norm": "101", "lat": 56.80, "lon": 60.60}
|
||
|
||
|
||
def test_geocode_ambiguous_not_matched(monkeypatch: Any) -> None:
|
||
"""Дубль-номер (ambiguous) не матчится: candidate '100' есть, но geom не пишется."""
|
||
_patch_fetch(
|
||
monkeypatch,
|
||
[
|
||
_node(1, 56.80, 60.60, {"name": "ТП-100"}),
|
||
_node(2, 56.85, 60.65, {"name": "ТП-100"}), # коллизия
|
||
],
|
||
)
|
||
db = _FakeSession(candidate_norms=["100"], rowcount=1)
|
||
result = og.geocode_tp_from_osm(db=db, apply=True)
|
||
|
||
assert result["ambiguous"] == 1
|
||
assert result["matched"] == 0
|
||
assert result["updated"] == 0
|
||
assert all("UPDATE" not in sql for sql, _ in db.calls)
|
||
|
||
|
||
def test_geocode_unmatched_candidate_skipped(monkeypatch: Any) -> None:
|
||
"""Кандидат без OSM-номера просто скипается (matched=0), без падения."""
|
||
_patch_fetch(monkeypatch, [_node(1, 56.80, 60.60, {"name": "ТП-777"})])
|
||
db = _FakeSession(candidate_norms=["101"], rowcount=1) # 101 нет в OSM
|
||
result = og.geocode_tp_from_osm(db=db, apply=True)
|
||
assert result["matched"] == 0
|
||
assert result["updated"] == 0
|
||
|
||
|
||
# ── Celery / beat регистрация ────────────────────────────────────────────────
|
||
|
||
|
||
def test_celery_task_registered() -> None:
|
||
"""Таск sync_osm_tp_geocode регистрируется при импорте модуля тасков.
|
||
|
||
Модуль в celery_app.include — на worker-старте импортируется и таск попадает в
|
||
celery_app.tasks. В тесте импорт делаем явно (Celery не бутит воркер).
|
||
"""
|
||
import app.workers.tasks.connection_capacity_sync # noqa: F401 — импорт регистрирует таск
|
||
from app.workers.celery_app import celery_app
|
||
|
||
assert "tasks.connection_capacity_sync.sync_osm_tp_geocode" in celery_app.tasks
|
||
|
||
|
||
def test_beat_schedule_entry() -> None:
|
||
"""Beat содержит еженедельную вторник-05:45 запись osm-tp-geocode."""
|
||
from app.workers.beat_schedule import build_beat_schedule
|
||
|
||
schedule = build_beat_schedule()
|
||
assert "osm-tp-geocode-weekly" in schedule
|
||
entry = schedule["osm-tp-geocode-weekly"]
|
||
assert entry["task"] == "tasks.connection_capacity_sync.sync_osm_tp_geocode"
|