"""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: """ каталога: путь /uploads/default//<имя> + опц. дата публикации рядом.""" name = _form6_name(kind, month, year) href = f"/uploads/default/{hex_dir}/{name}" asof_html = f"{asof}" if asof else "" return f'
{name}{asof_html}
' def test_extract_form6_links_parses_period_and_asof() -> None: """Период «(за|на) <месяц> <год>» + asof (DD.MM.YYYY рядом) извлекаются; URL к apex.""" html = ( "" + _catalog_link("за", "май", 2026, "a1b2c3", asof="03.06.2026") + "" ) 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 = "" + _catalog_link("на", "июль", 2026, "deadbeef") + "" 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 = ( "" # годовой план — приложение №2, .xls → НЕ форма 6 приложения №4 '' "годовой план" # посторонний файл 'прайс' # валидная помесячная форма 6 + _catalog_link("за", "апрель", 2026, "cc22") + "" ) 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 = ( "" + _catalog_link("за", "апрель", 2026, "h1") + _catalog_link("за", "май", 2026, "h2") # свежайший ФАКТ + _catalog_link("на", "июль", 2026, "h3") # прогноз новее по (год,месяц) + "" ) 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 = ( "" + _catalog_link("на", "июнь", 2026, "f1") + _catalog_link("на", "август", 2026, "f2") # свежайший прогноз + "" ) 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