gendesign/backend/tests/test_ekgas_outlet_loader.py
bot-backend cc9516ab3d
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m6s
Deploy / build-worker (push) Successful in 3m8s
Deploy / deploy (push) Successful in 1m34s
feat(ekgas): геокод точек выхода через DaData (#2119 B2, PR-3/4) (#2249)
2026-07-03 03:19:18 +00:00

689 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit-тесты загрузчика точек выхода ГРС «Екатеринбурггаз» (ekgas.ru) (#2119 B2, PR-2).
Pure / mock-based — без реальной сети и БД. Покрывает:
- extract_form6_links + select_latest_file: выбор свежайшего файла из синтетического
каталога с ПРИОРИТЕТОМ ФАКТА («за») над прогнозом («на») — даже когда «на» новее по
(год, месяц) (отдельный тест); годовой .xls / не-форма-6 отфильтрованы;
- parse_outlet_sheet: РЕАЛЬНАЯ сетка (подписи row 10, нумерация row 11, данные с row 12),
forward-fill block-marker ГРС, «0.000*»→needs_calc (НЕ ноль), дефицит 0.084 сохранён,
сноска-скип single-cell;
- extract_address: скобочный/хвостовой адрес извлечён, юрлицо без адреса → None;
- _dedup_outlet_names: суффикс « (N)»; _upsert_outlet_rows: счётчики (mock-DB);
- geocode_gas_outlets (PR-3): префикс города, bbox-гейт (Москва → failed), кэш
повторного адреса, dry-run не пишет, apply пишет geom, cap вызовов DaData.
"""
from __future__ import annotations
import io
from contextlib import contextmanager
from datetime import date
from typing import Any
import openpyxl
import pytest
from app.services.site_finder import ekgas_outlet_loader as ek
# ── Каталог: синтетический HTML со ссылками на формы 6 ────────────────────────
def _form6_name(kind: str, month: str, year: int) -> str:
"""Имя файла формы 6 приложения №4 в реальном формате ekgas."""
return (
f"Форма 6 приложения № 4 к Приказу ФАС России № 960 от 08.12.2022г. "
f"{kind} {month} {year}г..xlsx"
)
def _catalog_link(kind: str, month: str, year: int, hex_dir: str, asof: str = "") -> str:
"""<a> каталога: путь /uploads/default/<hex>/<имя> + опц. дата публикации рядом."""
name = _form6_name(kind, month, year)
href = f"/uploads/default/{hex_dir}/{name}"
asof_html = f"<span>{asof}</span>" if asof else ""
return f'<div class="row"><a href="{href}">{name}</a>{asof_html}</div>'
def test_extract_form6_links_parses_period_and_asof() -> None:
"""Период «(за|на) <месяц> <год>» + asof (DD.MM.YYYY рядом) извлекаются; URL к apex."""
html = (
"<html><body>"
+ _catalog_link("за", "май", 2026, "a1b2c3", asof="03.06.2026")
+ "</body></html>"
)
links = ek.extract_form6_links(html)
assert len(links) == 1
link = links[0]
assert link["period_month"] == date(2026, 5, 1)
assert link["is_forecast"] is False # «за» = факт
assert link["asof"] == date(2026, 6, 3)
assert link["url"].startswith("https://ekgas.ru/uploads/default/a1b2c3/")
def test_extract_form6_links_na_is_forecast() -> None:
"""«на <месяц>» → is_forecast=True (прогноз)."""
html = "<html>" + _catalog_link("на", "июль", 2026, "deadbeef") + "</html>"
links = ek.extract_form6_links(html)
assert len(links) == 1
assert links[0]["is_forecast"] is True
assert links[0]["period_month"] == date(2026, 7, 1)
def test_extract_form6_links_skips_yearly_xls_and_foreign() -> None:
"""Годовой «Форма №6 (Приложение №2) … план на <год>» (.xls) и прочие файлы — скип."""
html = (
"<html>"
# годовой план — приложение №2, .xls → НЕ форма 6 приложения №4
'<a href="/uploads/default/ff00/Форма №6 (Приложение №2) план на 2026г..xls">'
"годовой план</a>"
# посторонний файл
'<a href="/uploads/default/aa11/Прайс-лист.pdf">прайс</a>'
# валидная помесячная форма 6
+ _catalog_link("за", "апрель", 2026, "cc22")
+ "</html>"
)
links = ek.extract_form6_links(html)
assert len(links) == 1
assert links[0]["period_month"] == date(2026, 4, 1)
def test_select_latest_file_fact_beats_newer_forecast() -> None:
"""ПРИОРИТЕТ ФАКТА: «за май 2026» выбирается, хотя «на июль 2026» новее по (год,месяц).
Документирует правило select_latest_file: если есть хоть один файл «за» (факт),
берём максимальный (год, месяц) СРЕДИ «за», даже если прогноз «на» новее. Прогноз
берётся ТОЛЬКО когда файлов «за» нет вообще.
"""
html = (
"<html>"
+ _catalog_link("за", "апрель", 2026, "h1")
+ _catalog_link("за", "май", 2026, "h2") # свежайший ФАКТ
+ _catalog_link("на", "июль", 2026, "h3") # прогноз новее по (год,месяц)
+ "</html>"
)
chosen = ek.select_latest_file(ek.extract_form6_links(html))
assert chosen is not None
assert chosen["period_month"] == date(2026, 5, 1) # «за май», не «на июль»
assert chosen["is_forecast"] is False
def test_select_latest_file_forecast_only_fallback() -> None:
"""Файлов «за» нет вообще → берём свежайший «на» (прогноз, is_forecast=True)."""
html = (
"<html>"
+ _catalog_link("на", "июнь", 2026, "f1")
+ _catalog_link("на", "август", 2026, "f2") # свежайший прогноз
+ "</html>"
)
chosen = ek.select_latest_file(ek.extract_form6_links(html))
assert chosen is not None
assert chosen["period_month"] == date(2026, 8, 1)
assert chosen["is_forecast"] is True
def test_select_latest_file_empty_returns_none() -> None:
assert ek.select_latest_file([]) is None
# ── parse_outlet_sheet: РЕАЛЬНАЯ сетка (row 10 подписи, 11 нумерация, 12+ данные) ──
def _pad(cells: dict[int, object], width: int = 7) -> list[object]:
"""Строка листа из width колонок: dict{col_index: value} → выровненный ряд."""
row: list[object] = [None] * width
for idx, value in cells.items():
row[idx] = value
return row
def _outlet_xlsx_bytes() -> bytes:
"""XLSX формы 6 — сокращённая РЕАЛЬНАЯ сетка (собрана openpyxl'ом как на проде).
Строки 1-9 пустые/шапка, row 10 = подписи, row 11 = нумерация 1..7, данные с row 12:
* 2 блока ГРС (block-marker в col0 только в 1-й строке блока → forward-fill);
* «0.000*» в col6 → needs_calc (НЕ ноль);
* отрицательный free (дефицит 0.084) сохраняется знаком;
* адрес в «Точке выхода» → address_extracted; юрлицо без адреса → None;
* последняя строка — сноска про «*» (single-cell) → скип.
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Форма 6"
# Строки 1-9: пустая шапка формы (title/период/пусто) — до подписей.
for _ in range(9):
ws.append(_pad({}))
# row 10 — подписи колонок.
ws.append(
_pad(
{
0: "Точка входа в газораспределительную сеть",
1: "Точка выхода из газораспределительной сети",
2: "Наименование потребителя",
3: "Номер группы газопотребления/ транзит",
4: "Объемы газа в соответствии с поступившими заявками, млн.куб.м",
5: "Объемы газа в соответствии с удовлетворенными заявками, млн.куб.м",
6: "Свободная мощность газораспределительной сети, млн.куб.м",
}
)
)
# row 11 — нумерация столбцов 1..7.
ws.append(_pad({0: 1, 1: 2, 2: 3, 3: 4, 4: 5, 5: 6, 6: 7}))
# row 12 — блок 1, первая строка (block-marker ГРС в col0).
ws.append(
_pad(
{
0: "ГРС Арамиль",
1: 'ООО "Тепло" (г.Екатеринбург, Складской проезд,4а)',
2: "котельная",
3: "5 - 0,1-1млн.куб.м",
4: 1.2,
5: 0.8,
6: 0.4,
}
)
)
# row 13 — блок 1, вторая строка (col0 ПУСТ → forward-fill «ГРС Арамиль»),
# свободная мощность «0.000*» → needs_calc.
ws.append(
_pad(
{
1: "ИП Рошканяну Владимир Антонович", # юрлицо без адреса → address None
2: "промплощадка",
4: 0.05,
5: 0.05,
6: "0.000*", # НЕ ноль — требует гидрорасчёта
}
)
)
# row 14 — блок 2, первая строка (новый block-marker), дефицит 0.084.
ws.append(
_pad(
{
0: "ГРС Кольцово",
1: 'ЗАО "Луч", пер. Базовый, 17', # адрес хвостом
2: "ТЭЦ",
3: "4",
4: 2.5,
5: 2.5,
6: -0.084, # дефицит — знак сохраняется
}
)
)
# row 15 — сноска про «*» (single-cell) → скип.
ws.append(_pad({0: "* требуется гидравлический расчёт свободной мощности"}))
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def test_parse_outlet_sheet_forward_fill_and_markers() -> None:
"""Forward-fill ГРС, «0.000*»→needs_calc, дефицит сохранён, адрес извлечён, сноска-скип."""
records = ek.parse_outlet_sheet(_outlet_xlsx_bytes())
# 3 строки данных (сноска single-cell отброшена).
assert len(records) == 3
r0, r1, r2 = records
# Блок 1, строка 1.
assert r0["grs_name"] == "ГРС Арамиль"
assert r0["free_capacity_mln_m3"] == 0.4
assert r0["free_capacity_needs_calc"] is False
assert r0["address_extracted"] == "г.Екатеринбург, Складской проезд,4а"
# Блок 1, строка 2 — forward-fill той же ГРС + «0.000*» → needs_calc, NULL free.
assert r1["grs_name"] == "ГРС Арамиль" # col0 был пуст → протянут
assert r1["free_capacity_mln_m3"] is None
assert r1["free_capacity_needs_calc"] is True # «0.000*» — НЕ ноль
assert r1["address_extracted"] is None # ИП без адреса
# Блок 2 — новый block-marker + дефицит 0.084 (знак сохранён) + адрес хвостом.
assert r2["grs_name"] == "ГРС Кольцово"
assert r2["free_capacity_mln_m3"] == -0.084
assert r2["free_capacity_needs_calc"] is False
assert r2["address_extracted"] == "пер. Базовый, 17"
# raw_cells — сырая строка списком (для jsonb raw).
assert r0["raw_cells"][0] == "ГРС Арамиль"
def _pad8(cells: dict[int, object]) -> list[object]:
"""Строка live-layout (8 колонок: № п/п + 7 данных)."""
return _pad(cells, width=8)
def test_parse_outlet_sheet_live_layout_num_column_shifted() -> None:
"""ЖИВОЙ прод-layout за май 2026: слева колонка «№ п/п», header на row 11,
данные с row 13 (сдвиг +1 строка и +1 колонка против разведки). Label-детект
обязан распарсить без хардкод-индексов — первый прогон дал 5 строк из 1037."""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Май 2026"
for _ in range(10):
ws.append(_pad8({}))
ws.append(
_pad8(
{
0: "№ п/п",
1: "Точка входа в газораспределительную сеть",
2: "Точка выхода из газораспределительной сети",
3: "Наименование потребителя",
4: "Номер группы газопотребления/ транзит",
5: "Объемы газа в соответствии с поступившими заявками, млн.куб.м",
6: "Объемы газа в соответствии с удовлетворенными заявками, млн.куб.м",
7: "Свободная мощность газораспределительной сети, млн.куб.м",
}
)
)
ws.append(_pad8({1: "1", 2: "2", 3: "3", 4: "4", 5: "5", 6: "6", 7: "7"}))
ws.append(
_pad8(
{
0: "1",
1: "ГРС-1 Свердловск; ГРС-2 Екатеринбург",
2: "МПРО Приход (г.Екатеринбург, ул. Тверитина, 20)",
3: "котельная",
4: "8 - 0,01-500млн.куб.м",
5: 0.006,
6: 0.006,
7: 0,
}
)
)
ws.append(_pad8({2: "ИП Без Адреса", 3: "котельная", 5: 0.001, 6: 0.003, 7: -0.002}))
buf = io.BytesIO()
wb.save(buf)
records = ek.parse_outlet_sheet(buf.getvalue())
assert len(records) == 2
assert records[0]["grs_name"].startswith("ГРС-1")
assert records[0]["volume_requested_mln_m3"] == 0.006
assert records[0]["address_extracted"] is not None
assert records[1]["grs_name"].startswith("ГРС-1") # forward-fill через № п/п layout
assert records[1]["free_capacity_mln_m3"] == -0.002
def test_parse_outlet_sheet_volumes_and_meta() -> None:
"""Объёмы заявок/удовлетворено и тип/группа парсятся из своих колонок."""
records = ek.parse_outlet_sheet(_outlet_xlsx_bytes())
r0 = records[0]
assert r0["volume_requested_mln_m3"] == 1.2
assert r0["volume_approved_mln_m3"] == 0.8
assert r0["consumer_type"] == "котельная"
assert r0["consumption_group"] == "5 - 0,1-1млн.куб.м"
# ── extract_address: консервативный адрес-экстрактор ─────────────────────────
def test_extract_address_parenthetical() -> None:
"""«(г.Екатеринбург, Складской проезд,4а)» → адрес извлечён."""
name = 'ООО "Тепло" (г.Екатеринбург, Складской проезд,4а)'
assert ek.extract_address(name) == "г.Екатеринбург, Складской проезд,4а"
def test_extract_address_trailing() -> None:
"""«, пер. Базовый, 17» хвостом → адрес извлечён."""
assert ek.extract_address('ЗАО "Луч", пер. Базовый, 17') == "пер. Базовый, 17"
def test_extract_address_street_with_house() -> None:
"""«г.…, ул. К.Маркса,31» → адрес с улицей и домом."""
got = ek.extract_address("Котельная, г. Реж, ул. К.Маркса,31")
assert got is not None
assert "К.Маркса" in got
assert got.rstrip().endswith("31")
def test_extract_address_legal_entity_returns_none() -> None:
"""Юрлицо без адресного сигнала → None (не выдумываем)."""
assert ek.extract_address("ИП Рошканяну Владимир Антонович") is None
def test_extract_address_no_street_signal_returns_none() -> None:
"""Название с числом, но без улицы/города («ООО ТЭЦ-2») → None (число ≠ дом)."""
assert ek.extract_address('ООО "ТЭЦ-2"') is None
assert ek.extract_address("") is None
# ── _parse_free_capacity: «0.000*» vs настоящий 0 vs дефицит ─────────────────
def test_parse_free_capacity_needs_calc_marker() -> None:
"""«0.000*» → (None, True). Вариации с запятой/пробелом тоже маркер."""
assert ek._parse_free_capacity("0.000*") == (None, True)
assert ek._parse_free_capacity("0,000 *") == (None, True)
def test_parse_free_capacity_real_zero_and_deficit() -> None:
"""Настоящий 0 (без «*») → (0.0, False); дефицит 0.084 → (0.084, False)."""
assert ek._parse_free_capacity(0.0) == (0.0, False)
assert ek._parse_free_capacity(-0.084) == (-0.084, False)
assert ek._parse_free_capacity("") == (None, False)
# ── _dedup_outlet_names: повторы outlet_name внутри файла ─────────────────────
def test_dedup_outlet_names_suffix() -> None:
"""Одинаковые outlet_name у одной ГРС → 2-му и далее суффикс « (N)» (не схлопывать)."""
records = [
{"grs_name": "ГРС Арти", "outlet_name": "Котельная №1"},
{"grs_name": "ГРС Арти", "outlet_name": "Котельная №1"}, # дубль
{"grs_name": "ГРС Арти", "outlet_name": "Котельная №1"}, # ещё дубль
{"grs_name": "ГРС Кольцово", "outlet_name": "Котельная №1"}, # другая ГРС → не дубль
]
ek._dedup_outlet_names(records)
assert [r["outlet_name"] for r in records] == [
"Котельная №1",
"Котельная №1 (2)",
"Котельная №1 (3)",
"Котельная №1", # другая ГРС — суффикс не нужен
]
# ── Fake DB (SAVEPOINT + execute), как в test_eias_heat_loader.py ─────────────
class _FakeResult:
def __init__(self, scalar_value: Any = True) -> None:
self._scalar = scalar_value
def scalar(self) -> Any:
return self._scalar
class _FakeSession:
"""Мини-заглушка Session: собирает execute-параметры, поддерживает begin_nested."""
def __init__(self, scalar_value: Any = True) -> None:
self.calls: list[tuple[str, Any]] = []
self._scalar = scalar_value
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
yield
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
self.calls.append((str(stmt), params))
return _FakeResult(self._scalar)
def _record(
grs: str,
outlet: str,
free: float | None,
needs_calc: bool = False,
address: str | None = None,
) -> dict:
return {
"grs_name": grs,
"outlet_name": outlet,
"consumer_type": "котельная",
"consumption_group": "4",
"volume_requested_mln_m3": 1.0,
"volume_approved_mln_m3": 0.9,
"free_capacity_mln_m3": free,
"free_capacity_needs_calc": needs_calc,
"address_extracted": address,
"raw_cells": [grs, outlet, "котельная"],
}
_META = {
"period_month": date(2026, 5, 1),
"is_forecast": False,
"asof": date(2026, 6, 3),
"source_url": "https://ekgas.ru/uploads/default/h1/f.xlsx",
}
def test_upsert_outlet_rows_counters_and_params() -> None:
"""Счётчики inserted/needs_calc/with_address; params корректны; CAST jsonb + ON CONFLICT."""
db = _FakeSession(scalar_value=True) # xmax=0 → insert
records = [
_record("ГРС Арамиль", "ООО Тепло", 0.4, address="пер. Базовый, 17"),
_record("ГРС Арамиль", "ИП Рошканяну", None, needs_calc=True),
_record("ГРС Кольцово", "ЗАО Луч", -0.084), # дефицит: знак сохраняется
]
counts = ek._upsert_outlet_rows(db, records, {**_META, "source_url": _META["source_url"]})
assert counts == {
"rows": 3,
"inserted": 3,
"updated": 0,
"skipped": 0,
"needs_calc": 1,
"with_address": 1,
}
assert len(db.calls) == 3
sql, first = db.calls[0]
assert first["grs_name"] == "ГРС Арамиль"
assert first["grs_name_norm"] == "грс арамиль" # нормализация из B1
assert first["free"] == 0.4
assert first["period_month"] == date(2026, 5, 1)
assert first["is_forecast"] is False
assert first["address"] == "пер. Базовый, 17"
assert '"cells"' in first["raw"]
assert "CAST(:raw AS jsonb)" in sql
assert "ON CONFLICT (grs_name_norm, outlet_name, period_month, is_forecast)" in sql
# needs_calc-строка: free NULL, needs_calc True прокинуты.
assert db.calls[1][1]["free"] is None
assert db.calls[1][1]["needs_calc"] is True
# дефицит сохранён знаком.
assert db.calls[2][1]["free"] == -0.084
def test_upsert_outlet_rows_update_counter() -> None:
"""scalar()=False (xmax≠0) → строка учтена как update, не insert."""
db = _FakeSession(scalar_value=False)
counts = ek._upsert_outlet_rows(db, [_record("ГРС X", "outlet", 1.0)], _META)
assert counts["updated"] == 1
assert counts["inserted"] == 0
def test_upsert_outlet_rows_skip_on_error() -> None:
"""execute бросает → строка skipped, счётчики insert/update не растут."""
class _RaisingSession(_FakeSession):
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
raise RuntimeError("db boom")
db = _RaisingSession()
counts = ek._upsert_outlet_rows(db, [_record("ГРС Y", "outlet", 2.0)], _META)
assert counts == {
"rows": 1,
"inserted": 0,
"updated": 0,
"skipped": 1,
"needs_calc": 0,
"with_address": 0,
}
# ── Геокод address_extracted → geom (PR-3) ────────────────────────────────────
# Координаты внутри bbox ЕКБ (центр — пл. 1905 года ≈ 56.838, 60.597).
_EKB_COORDS = (56.838, 60.597)
# Москва — заведомо вне bbox ЕКБ (DaData «дорисовал» чужой город на кривом адресе).
_MSK_COORDS = (55.7558, 37.6173)
class _GeocodeSession:
"""Fake Session для geocode_gas_outlets: отдаёт кандидатов на SELECT, собирает UPDATE.
execute(SELECT) → .all() = список (id, address); execute(UPDATE) → пишет params
в updates. begin_nested — no-op SAVEPOINT, commit — считает вызовы.
"""
def __init__(self, candidates: list[tuple[int, str]]) -> None:
self._candidates = candidates
self.updates: list[dict] = []
self.commits = 0
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
yield
def execute(self, stmt: Any, params: Any = None) -> Any:
sql = str(stmt)
if "UPDATE gas_grs_outlet_points" in sql:
self.updates.append(params)
return _FakeResult(None)
# SELECT кандидатов.
rows = [(cid, addr) for cid, addr in self._candidates]
return _SelectResult(rows)
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None: # pragma: no cover - defensive
pass
class _SelectResult:
def __init__(self, rows: list[tuple[int, str]]) -> None:
self._rows = rows
def all(self) -> list[tuple[int, str]]:
return self._rows
def _patch_geocoder(monkeypatch: pytest.MonkeyPatch, mapping: dict) -> list[str]:
"""Мокает clean_address: query→coords по mapping (default _EKB_COORDS).
Возвращает список реально переданных в DaData query-строк (для проверки префикса
города и cap-счётчика вызовов).
"""
calls: list[str] = []
def _fake(query: str) -> tuple[float, float] | None:
calls.append(query)
if query in mapping:
return mapping[query]
return _EKB_COORDS
monkeypatch.setattr(ek, "clean_address", _fake)
return calls
def test_geocode_prefixes_city(monkeypatch: pytest.MonkeyPatch) -> None:
"""Адрес без города → DaData-запрос префиксован «г Екатеринбург, »."""
calls = _patch_geocoder(monkeypatch, {})
db = _GeocodeSession([(1, "ул. Стрелочников, д. 2")])
counts = ek.geocode_gas_outlets(db, apply=True)
assert calls == ["г Екатеринбург, ул. Стрелочников, д. 2"]
assert counts["geocoded"] == 1
assert counts["failed"] == 0
def test_geocode_keeps_existing_city(monkeypatch: pytest.MonkeyPatch) -> None:
"""Адрес с городом («г.Екатеринбург, …») → префикс НЕ дублируется."""
calls = _patch_geocoder(monkeypatch, {})
db = _GeocodeSession([(1, "г.Екатеринбург, Складской проезд, 4а")])
ek.geocode_gas_outlets(db, apply=True)
assert calls == ["г.Екатеринбург, Складской проезд, 4а"]
def test_geocode_bbox_gate_rejects_moscow(monkeypatch: pytest.MonkeyPatch) -> None:
"""Координата вне bbox ЕКБ (Москва) → failed, geom НЕ пишем."""
query = "г Екатеринбург, ул. Кривая, 1"
_patch_geocoder(monkeypatch, {query: _MSK_COORDS})
db = _GeocodeSession([(1, "ул. Кривая, 1")])
counts = ek.geocode_gas_outlets(db, apply=True)
assert counts["geocoded"] == 0
assert counts["failed"] == 1
assert db.updates == [] # geom не тронут
def test_geocode_caches_repeated_address(monkeypatch: pytest.MonkeyPatch) -> None:
"""Один и тот же адрес в 2 строках → DaData дёрнут ОДИН раз, geom у обеих."""
calls = _patch_geocoder(monkeypatch, {})
db = _GeocodeSession([(1, "ул. Общая, 5"), (2, "ул. Общая, 5")])
counts = ek.geocode_gas_outlets(db, apply=True)
assert len(calls) == 1 # кэш: второй адрес не жёг квоту
assert counts["dadata_calls"] == 1
assert counts["geocoded"] == 2
assert {u["id"] for u in db.updates} == {1, 2}
def test_geocode_dry_run_does_not_write(monkeypatch: pytest.MonkeyPatch) -> None:
"""Dry-run (apply=False): считает geocoded, но НЕ UPDATE-ит и НЕ commit-ит."""
_patch_geocoder(monkeypatch, {})
db = _GeocodeSession([(1, "ул. Стрелочников, д. 2")])
counts = ek.geocode_gas_outlets(db, apply=False)
assert counts["geocoded"] == 1
assert counts["applied"] is False
assert db.updates == []
assert db.commits == 0
def test_geocode_apply_writes_geom(monkeypatch: pytest.MonkeyPatch) -> None:
"""apply=True: UPDATE с CAST(:lon/:lat AS float) + ST_SetSRID(ST_MakePoint...)."""
_patch_geocoder(monkeypatch, {})
db = _GeocodeSession([(7, "ул. Стрелочников, д. 2")])
counts = ek.geocode_gas_outlets(db, apply=True)
assert counts["geocoded"] == 1
assert db.commits == 1
assert len(db.updates) == 1
upd = db.updates[0]
assert upd["id"] == 7
assert upd["lat"] == _EKB_COORDS[0]
assert upd["lon"] == _EKB_COORDS[1]
def test_geocode_update_sql_uses_cast_and_postgis() -> None:
"""SQL UPDATE: CAST(:lon/:lat AS float) (psycopg3-канон), ST_SetSRID+ST_MakePoint, 4326."""
sql = str(ek._GEOCODE_UPDATE_SQL)
assert "CAST(:lon AS float)" in sql
assert "CAST(:lat AS float)" in sql
assert "ST_SetSRID" in sql
assert "ST_MakePoint" in sql
assert "4326" in sql
assert "geom IS NULL" in sql # idempotent — не переписываем уже геокоженное
# никакого :param::type (psycopg3 игнорит) в этом файле.
assert "::" not in sql
def test_geocode_cap_limits_dadata_calls(monkeypatch: pytest.MonkeyPatch) -> None:
"""Превышение cap вызовов DaData → cap_hit, остаток failed без обращения к DaData."""
calls = _patch_geocoder(monkeypatch, {})
monkeypatch.setattr(ek, "_GEO_DADATA_CAP", 2)
# 3 УНИКАЛЬНЫХ адреса, cap=2 → 3-й не геокодится.
db = _GeocodeSession([(1, "ул. A, 1"), (2, "ул. B, 2"), (3, "ул. C, 3")])
counts = ek.geocode_gas_outlets(db, apply=True)
assert counts["dadata_calls"] == 2
assert counts["geocoded"] == 2
assert counts["failed"] == 1
assert counts["cap_hit"] is True
assert len(calls) == 2