"""Unit-тесты загрузчиков «точек подключения со свободной мощностью» (#2119). Pure / mock-based — без реальной сети и БД. Покрывает: - normalize_sc_name: «ПС 110/35/10 Нижне-Исетская» ↔ «Нижне-Исетская», ё, тире. - parse_voltage_class: извлечение класса напряжения из имени ЦП. - _map_load_index: sc_indexload_id → open/limited/closed/None. - sanitize_tp_capacity_mva: кВА-санитайз (÷1000 для ТП > порога, РП не трогаем). - parse_reserve_number / parse_water_number: запятая, знак (дефицит <0), «н/д». - infer_period: «1kv.-2026» → '2026-Q1'. - parse_docx_table_rows: forward-fill вертикального merge (vMerge) на xml-фрагменте. - extract_water_rows: column-aligned извлечение ЦСВ/ЦСК из РЕАЛЬНОЙ сетки прода — оба layout (water 6-колонок «длинный» / sewerage 5-колонок «блочный»); резерв из строки «Резерв мощности…», мусор (№ п/п, рубрики, «Итого:», шапки) отфильтрован, нули сохранены — регрессия прод-бага #2126. - load_tp_rp_reserves: row-offset (данные с строки 9) на синтетическом workbook. - load_cp_reserves: row-offset (данные с строки 8) + матч по sc_name_norm. """ from __future__ import annotations import io from contextlib import contextmanager from typing import Any import openpyxl from app.services.site_finder import rosseti_reserve_loader as rr from app.services.site_finder import rosseti_wfs_loader as rw from app.services.site_finder import vodokanal_reserve_loader as vk # ── normalize_sc_name ───────────────────────────────────────────────────────── def test_normalize_strips_ps_and_voltage_prefix() -> None: """«ПС 110/35/10 Нижне-Исетская» → «нижне-исетская» (совпадает с голым именем).""" a = rw.normalize_sc_name("ПС 110/35/10 Нижне-Исетская") b = rw.normalize_sc_name("Нижне-Исетская") assert a == b == "нижне-исетская" def test_normalize_yo_to_e() -> None: """ё → е при нормализации.""" assert rw.normalize_sc_name("Берёзовская") == rw.normalize_sc_name("Березовская") assert rw.normalize_sc_name("Берёзовская") == "березовская" def test_normalize_dash_variants_collapse() -> None: """Разные тире (—, –, -) и пробелы вокруг схлопываются в один дефис.""" assert rw.normalize_sc_name("Нижне — Исетская") == "нижне-исетская" assert rw.normalize_sc_name("Нижне–Исетская") == "нижне-исетская" def test_normalize_quotes_removed() -> None: """«ПС "Уктус"» → «уктус» (кавычки и префикс срезаны).""" assert rw.normalize_sc_name('ПС "Уктус"') == "уктус" def test_normalize_empty() -> None: assert rw.normalize_sc_name(None) == "" assert rw.normalize_sc_name("") == "" # ── parse_voltage_class ─────────────────────────────────────────────────────── def test_parse_voltage_class_triple() -> None: assert rw.parse_voltage_class("ПС 110/35/10 Нижне-Исетская") == "110/35/10" def test_parse_voltage_class_double() -> None: assert rw.parse_voltage_class("ПС 110/10 Уктус") == "110/10" def test_parse_voltage_class_none() -> None: assert rw.parse_voltage_class("Уктус") is None assert rw.parse_voltage_class(None) is None # ── _map_load_index ─────────────────────────────────────────────────────────── def test_map_load_index_known() -> None: assert rw._map_load_index({"sc_indexload_id": 256184}) == "open" assert rw._map_load_index({"sc_indexload_id": 256186}) == "limited" assert rw._map_load_index({"sc_indexload_id": 256188}) == "closed" def test_map_load_index_string_id_coerced() -> None: """id приходит строкой из json — коэрсим в int.""" assert rw._map_load_index({"sc_indexload_id": "256184"}) == "open" def test_map_load_index_unknown_and_missing() -> None: assert rw._map_load_index({"sc_indexload_id": 999999}) is None assert rw._map_load_index({}) is None assert rw._map_load_index({"sc_indexload_id": "мусор"}) is None # ── sanitize_tp_capacity_mva (кВА-санитайз) ─────────────────────────────────── def test_kva_sanitize_tp_above_threshold_divided() -> None: """ТП с installed=630 (кВА в колонке МВА) → 0.63 МВА, флаг True.""" val, sanitized = rr.sanitize_tp_capacity_mva(630.0, is_rp=False) assert sanitized is True assert val == 0.63 def test_kva_sanitize_tp_below_threshold_kept() -> None: """ТП с installed=1.6 МВА (правдоподобно) → не трогаем.""" val, sanitized = rr.sanitize_tp_capacity_mva(1.6, is_rp=False) assert sanitized is False assert val == 1.6 def test_kva_sanitize_rp_never_divided() -> None: """РП с высокой мощностью легитимен → не делим даже выше порога.""" val, sanitized = rr.sanitize_tp_capacity_mva(10.0, is_rp=True) assert sanitized is False assert val == 10.0 def test_kva_sanitize_none() -> None: val, sanitized = rr.sanitize_tp_capacity_mva(None, is_rp=False) assert val is None and sanitized is False # ── parse_reserve_number ────────────────────────────────────────────────────── def test_parse_reserve_comma_decimal() -> None: assert rr.parse_reserve_number("1,63") == 1.63 def test_parse_reserve_negative_kept() -> None: """Отрицательный резерв (дефицит) сохраняет знак.""" assert rr.parse_reserve_number("-2,5") == -2.5 def test_parse_reserve_thousands_space() -> None: """Неразрывный пробел-разделитель тысяч убирается.""" assert rr.parse_reserve_number("1\xa0234,5") == 1234.5 def test_parse_reserve_nodata() -> None: assert rr.parse_reserve_number("н/д") is None assert rr.parse_reserve_number("") is None assert rr.parse_reserve_number(None) is None assert rr.parse_reserve_number("текст") is None def test_parse_reserve_numeric_passthrough() -> None: assert rr.parse_reserve_number(5) == 5.0 assert rr.parse_reserve_number(2.5) == 2.5 # ── parse_asof_date ─────────────────────────────────────────────────────────── def test_parse_asof_date_from_filename() -> None: d = rr.parse_asof_date("резерв по ЦП на 30.06.2026.xlsx") assert d is not None and d.isoformat() == "2026-06-30" def test_parse_asof_date_none() -> None: assert rr.parse_asof_date("no date here", None) is None # ── water: parse_water_number / infer_period ────────────────────────────────── def test_parse_water_negative_unicode_minus() -> None: """Юникод-минус −168,5 (дефицит) → -168.5.""" assert vk.parse_water_number("−168,5") == -168.5 def test_parse_water_nodata() -> None: assert vk.parse_water_number("н/д") is None assert vk.parse_water_number("") is None assert vk.parse_water_number(None) is None def test_infer_period_from_filename() -> None: assert vk.infer_period("dlya-sajta-1kv.-2026.docx") == "2026-Q1" assert vk.infer_period("dlya-sajta-3кв-2025.docx") == "2025-Q3" def test_infer_period_none() -> None: assert vk.infer_period("dlya-sajta.docx") is None # ── DOCX vMerge forward-fill ────────────────────────────────────────────────── _W = vk._W def _tc(text: str = "", vmerge: str | None = None) -> str: """Строит XML одной ячейки с опциональным vMerge.""" tc_pr = "" if vmerge == "restart": tc_pr = '' elif vmerge == "continue": tc_pr = "" return f"{tc_pr}{text}" def _doc_xml(rows: list[str]) -> bytes: """Строит минимальный document.xml с одной таблицей из строк-.""" trs = "".join(f"{r}" for r in rows) xml = f'{trs}' return xml.encode("utf-8") def test_docx_vmerge_forward_fill() -> None: """Ячейка колонки 0 в 'continue'-строках наследует значение из restart-строки.""" rows = [ _tc("ЗФС, ГСВ", vmerge="restart") + _tc("−168,5") + _tc("примечание"), _tc("", vmerge="continue") + _tc("12,3") + _tc(""), _tc("", vmerge="continue") + _tc("0,0") + _tc(""), ] matrix = vk.parse_docx_table_rows(_doc_xml(rows)) assert len(matrix) == 3 # forward-fill: имя системы протянуто во все три строки блока. assert matrix[0][0] == "ЗФС, ГСВ" assert matrix[1][0] == "ЗФС, ГСВ" assert matrix[2][0] == "ЗФС, ГСВ" # числовая колонка не наследуется. assert matrix[1][1] == "12,3" def test_docx_empty_no_table() -> None: xml = f''.encode() assert vk.parse_docx_table_rows(xml) == [] # ── extract_water_rows: РЕАЛЬНАЯ сетка прода (регрессия #2126) ──────────────── # # Обе матрицы — сокращённые копии РЕАЛЬНОЙ сетки, снятой с прода 2026-07-03 # (dlya-sajta-vodosnabzhenie-1kv.-2026v1.docx / vodootvedenie). Прод-баг #2126: # старый парсер брал «первое число строки» как резерв → в БД попадали № п/п # (1.1, 2.18…) вместо тыс.м³/сут, а рубрики анкеты / «Итого:» / шапки — как «системы». # Ниже фикстуры специально несут: шапку, рубрики (заявки/исполненные/отказы), # строку «Итого:», строку-легенду, ноль-резерв с «Отсутствие резерва мощности». # Наименования показателей формы (точь-в-точь с прода). _M_REQ = "Количество поданных и зарегистрированных заявок на подключение" _M_DONE = "Количество исполненных заявок на подключение" _M_REFUSED = "Количество заявок на подключение, по которым принято решение об отказе в подключении" _M_RESERVE_W = "Резерв мощности централизованной системы холодного водоснабжения" _M_RESERVE_S = "Резерв мощности централизованной системы водоотведения" def _water_block( num: str, name: str, req: str, refused: str, reserve: str, note: str ) -> list[list[str]]: """4 строки показателей одного ЦСВ водоснабжения (layout «water», 6 колонок).""" return [ [num, name, _M_REQ, "шт", req, ""], [num, name, _M_DONE, "шт", "0", ""], [num, name, _M_REFUSED, "шт", refused, ""], [num, name, _M_RESERVE_W, "тыс. м3/сут", reserve, note], ] def _real_water_matrix() -> list[list[str]]: """РЕАЛЬНАЯ сетка водоснабжения: шапка + 3 ЦСВ + ПТВ + «Итого:» + легенда.""" return [ ["1 квартал 2026г."], ["Информация о наличии\xa0(отсутствии) технической возможности…"], # шапка (col2 == 'Наименование показателя' → детект layout 'water') [ "№ ЦСВ", "Название ЦСВ", "Наименование показателя", "Ед. изм.", "Период 01.01.2026 -31.03.2026", "Примечание", ], # ЗФС,ГСВ: резерв −168,5 (дефицит), 49 заявок; № п/п '1.1.' НЕ должен стать резервом *_water_block("1.1.", 'ЦСВ "ЗФС, ГСВ"', "49", "0", "-168,5", ""), ["", "", "", "", "", ""], # разделитель-пустышка # Изоплит: ноль-резерв «Отсутствие резерва мощности» — сохраняем 0.0 + note *_water_block("1.3.", 'ЦСВ "Изоплит"', "0", "0", "0,0", "Отсутствие резерва мощности"), ["", "", "", "", "", ""], # Шабровский: положительный резерв 1,03 *_water_block("2.10.", 'ЦСВ "пос. Шабровский"', "0", "0", "1,03", ""), ["", "", "", "", "", ""], # ПТВ (№ ЦСВ = 'ПТВ', не числовой) — резерв 73,34, реальный крупный запас *_water_block("ПТВ", "ПТВ", "0", "0", "73,34", ""), ["", "", "", ""], # «Итого:» — агрегат по всем системам, НЕ резерв конкретной ЦСВ ["Итого:", _M_REQ, "шт", "65", ""], ["Итого:", _M_DONE, "шт", "41", ""], ["Итого:", _M_REFUSED, "шт", "0", ""], # легенда в конце ["ЦСВ – централизованная система водоснабжения", "", "", ""], ["ПТВ – производственно-техническое водоснабжение", "", "", ""], ] def _sewer_block(title: str, req: str, reserve: str, note: str) -> list[list[str]]: """Строка-заголовок системы + шапка + 4 показателя (layout «block», 5 колонок).""" return [ [title], # одиночная строка-заголовок системы (только col0) ["№ п/п", "Наименование показателя", "Единица измерения", "Период", "Примечание"], ["1", _M_REQ, "шт.", req, ""], ["2", _M_DONE, "шт.", "0", ""], ["3", _M_REFUSED, "шт.", "0", ""], ["4", _M_RESERVE_S, "тыс.м3/сут", reserve, note], ] def _real_sewer_matrix() -> list[list[str]]: """РЕАЛЬНАЯ сетка водоотведения: 3 системы-блока + «Итого:».""" return [ ["1 квартал 2026г."], ["Информация о наличии\xa0(отсутствии) технической возможности…"], # Южный: дефицит −296,85 (крупнейший), 62 заявки; '4' в col0 НЕ должен стать резервом *_sewer_block( 'Централизованная система водоотведения "Южный бассейн канализования"', "62", "-296,85", "Отсутствие резерва мощности", ), # Северный: положительный 2,02 *_sewer_block( 'Централизованная система водоотведения "Северный бассейн канализования"', "3", "2,02", "", ), # Транспортировка: ноль-резерв honestly сохраняем *_sewer_block( 'Транспортировка сточных вод ЦСВО "Бассейн канализования "Чусовское озеро"', "0", "0", "Отсутствие резерва мощности", ), ["", "", "", "", ""], ["Итого:", _M_REQ, "шт.", "65", ""], ["Итого:", _M_DONE, "шт.", "37", ""], ["Итого:", _M_REFUSED, "шт.", "0", ""], ] def test_vodokanal_water_column_aligned_real_grid() -> None: """Layout 'water': резерв из строки «Резерв мощности…», а НЕ № п/п; заявки верны.""" recs = vk.extract_water_rows(_real_water_matrix()) by_name = {r["system_name"]: r for r in recs} # ровно 4 системы (ЗФС, Изоплит, Шабровский, ПТВ) — ни одной рубрики/«Итого»/легенды assert len(recs) == 4 # РЕАЛЬНЫЕ резервы из правильной колонки (тыс.м³/сут), знак дефицита сохранён assert by_name['ЦСВ "ЗФС, ГСВ"']["reserve_thousand_m3_day"] == -168.5 assert by_name['ЦСВ "ЗФС, ГСВ"']["connections_requested"] == 49 assert by_name["ПТВ"]["reserve_thousand_m3_day"] == 73.34 assert by_name['ЦСВ "пос. Шабровский"']["reserve_thousand_m3_day"] == 1.03 # ни один резерв не равен № п/п (1.1 / 1.3 / 2.10) — прод-баг #2126 reserves = {r["reserve_thousand_m3_day"] for r in recs} assert 1.1 not in reserves assert 2.1 not in reserves def test_vodokanal_water_zero_reserve_preserved_with_note() -> None: """Ноль-резерв («Отсутствие резерва мощности») сохраняется честно: 0.0 + note.""" recs = vk.extract_water_rows(_real_water_matrix()) izoplit = next(r for r in recs if r["system_name"] == 'ЦСВ "Изоплит"') assert izoplit["reserve_thousand_m3_day"] == 0.0 # НЕ None, НЕ отброшен assert izoplit["note"] == "Отсутствие резерва мощности" def test_vodokanal_water_filters_rubrics_totals_legend() -> None: """Рубрики анкеты, «Итого:», шапка, легенда НЕ распознаются как системы.""" recs = vk.extract_water_rows(_real_water_matrix()) names = {r["system_name"] for r in recs} assert not any("Количество" in n for n in names) # рубрики заявок/отказов assert not any(n.startswith("Итого") for n in names) assert "Название ЦСВ" not in names and "№ ЦСВ" not in names # шапка assert not any(n.startswith("ЦСВ –") or n.startswith("ПТВ –") for n in names) # легенда def test_vodokanal_sewer_block_layout_real_grid() -> None: """Layout 'block' (водоотведение): имя — строка-заголовок, резерв из «Резерв…».""" recs = vk.extract_water_rows(_real_sewer_matrix()) by_name = {r["system_name"]: r for r in recs} assert len(recs) == 3 # 3 системы, «Итого:»/шапки отфильтрованы yuzhny = 'Централизованная система водоотведения "Южный бассейн канализования"' assert by_name[yuzhny]["reserve_thousand_m3_day"] == -296.85 # крупнейший дефицит assert by_name[yuzhny]["connections_requested"] == 62 severny = 'Централизованная система водоотведения "Северный бассейн канализования"' assert by_name[severny]["reserve_thousand_m3_day"] == 2.02 # '4' (№ п/п строки резерва) не попал в резерв assert 4.0 not in {r["reserve_thousand_m3_day"] for r in recs} def test_vodokanal_sewer_zero_reserve_preserved() -> None: """Ноль-резерв транспортировки сохранён (0.0 + note), не отброшен.""" recs = vk.extract_water_rows(_real_sewer_matrix()) transp = next(r for r in recs if r["system_name"].startswith("Транспортировка")) assert transp["reserve_thousand_m3_day"] == 0.0 assert transp["note"] == "Отсутствие резерва мощности" def test_vodokanal_water_no_header_returns_empty() -> None: """Нет строки-шапки (layout не распознан) → [] (graceful, warning наверху).""" matrix = [ ["мусор", "без шапки"], ["1.1.", 'ЦСВ "X"', "какой-то показатель", "шт", "5", ""], ] assert vk.extract_water_rows(matrix) == [] def test_vodokanal_dedupe_names_suffixes_byte_identical() -> None: """БАЙТ-В-БАЙТ одинаковые имена разводятся суффиксом « (N)» (защита UNIQUE).""" recs = [ {"system_name": "ЦСВ X", "reserve_thousand_m3_day": 1.0}, {"system_name": "ЦСВ X", "reserve_thousand_m3_day": -2.0}, {"system_name": "ЦСВ Y", "reserve_thousand_m3_day": 0.0}, ] out = vk._dedupe_names(recs) assert [r["system_name"] for r in out] == ["ЦСВ X", "ЦСВ X (2)", "ЦСВ Y"] # первое вхождение — без суффикса, его резерв 1.0 НЕ теряется assert out[0]["reserve_thousand_m3_day"] == 1.0 # ── Fake DB session для upsert-тестов (SAVEPOINT + execute) ─────────────────── class _FakeResult: def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None: self._scalar = scalar_value self.rowcount = rowcount def scalar(self) -> Any: return self._scalar class _FakeSession: """Мини-заглушка Session: собирает execute-параметры, поддерживает begin_nested.""" def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None: self.calls: list[tuple[str, Any]] = [] self._scalar = scalar_value 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: self.calls.append((str(stmt), params)) return _FakeResult(self._scalar, self._rowcount) def commit(self) -> None: self.committed = True def rollback(self) -> None: pass # ── load_water_reserves_from_docx: end-to-end (parse → dedupe → upsert) ──────── def _matrix_to_docx(matrix: list[list[str]]) -> bytes: """Матрица текстов → байты .docx (zip с word/document.xml, одна таблица).""" import zipfile as _zip from xml.sax.saxutils import escape trs = [] for row in matrix: tcs = "".join( f"{escape(c)}" for c in row ) trs.append(f"{tcs}") document = ( f'{"".join(trs)}' ) buf = io.BytesIO() with _zip.ZipFile(buf, "w", _zip.ZIP_DEFLATED) as zf: zf.writestr("word/document.xml", document.encode("utf-8")) return buf.getvalue() def test_vodokanal_load_from_docx_end_to_end() -> None: """Полный путь water: docx → 4 записи, реальные резервы прокинуты в upsert-params.""" db = _FakeSession(scalar_value=True, rowcount=1) docx = _matrix_to_docx(_real_water_matrix()) result = vk.load_water_reserves_from_docx( db, "water", docx, source_url="https://x/dlya-sajta-1kv.-2026.docx" ) assert result["records"] == 4 assert result["inserted"] == 4 assert db.committed # период инферится из имени файла источника assert db.calls[0][1]["period"] == "2026-Q1" by_name = {c[1]["system_name"]: c[1] for c in db.calls} # РЕАЛЬНЫЙ резерв ЗФС,ГСВ (−168,5), НЕ № п/п 1.1 — прод-баг #2126 assert by_name['ЦСВ "ЗФС, ГСВ"']["reserve"] == -168.5 assert by_name['ЦСВ "ЗФС, ГСВ"']["requested"] == 49 assert by_name["ПТВ"]["reserve"] == 73.34 # ноль-резерв Изоплита сохранён (0.0 + note), не отброшен и не None assert by_name['ЦСВ "Изоплит"']["reserve"] == 0.0 assert by_name['ЦСВ "Изоплит"']["note"] == "Отсутствие резерва мощности" def test_vodokanal_load_from_docx_sewer_layout() -> None: """Полный путь sewerage (block layout): 3 системы, крупнейший дефицит −296,85.""" db = _FakeSession(scalar_value=True, rowcount=1) docx = _matrix_to_docx(_real_sewer_matrix()) result = vk.load_water_reserves_from_docx(db, "sewerage", docx, source_url="x-3kv-2025.docx") assert result["records"] == 3 by_name = {c[1]["system_name"]: c[1] for c in db.calls} yuzhny = 'Централизованная система водоотведения "Южный бассейн канализования"' assert by_name[yuzhny]["reserve"] == -296.85 assert db.calls[0][1]["period"] == "2025-Q3" # ── load_tp_rp_reserves: row-offset (данные с строки 9) на синтетическом xlsx ── def _build_tp_rp_workbook() -> bytes: """Строит xlsx с верифицированной раскладкой ТП/РП: данные с СТРОКИ 9.""" wb = openpyxl.Workbook() ws = wb.active ws.title = "ТП РП на 30.06.2026" # Строки 1-8 — заголовки/нумерация (шум), должны игнорироваться. for r in range(1, 9): ws.cell(row=r, column=1, value=f"header-{r}") # Строка 9+ — данные. Колонки (1-based): name, РЭС, region, mun, кВ, inst, load, reserve. ws.append(["ТП-721", "Центральный РЭС", "СО", "Екатеринбург", "10", 630.0, 100.0, 530.0]) ws.append(["РП-5", "Западный РЭС", "СО", "Екатеринбург", "10", 8.0, 2.0, 6.0]) buf = io.BytesIO() wb.save(buf) return buf.getvalue() def test_load_tp_rp_row_offset_and_kva() -> None: """Данные читаются с строки 9; ТП-721 630→0.63 МВА (кВА-санитайз); РП не делится.""" db = _FakeSession(scalar_value=True, rowcount=1) result = rr.load_tp_rp_reserves(db, _build_tp_rp_workbook(), "тп рп на 30.06.2026.xlsx") assert result["rows"] == 2, result assert result["inserted"] == 2 assert result["kva_sanitized"] == 1 # только ТП-721 # Первый upsert — ТП-721 с санитайзнутой мощностью. tp_params = db.calls[0][1] assert tp_params["name"] == "ТП-721" assert tp_params["installed"] == 0.63 # 630 кВА → 0.63 МВА assert tp_params["reserve"] == 530.0 # РП-5 — мощность не тронута (легитимно высокая для РП, но тут 8 < порога всё равно). rp_params = db.calls[1][1] assert rp_params["name"] == "РП-5" assert rp_params["installed"] == 8.0 # ── load_cp_reserves: row-offset (строка 8) + матч по sc_name_norm ───────────── def _build_cp_workbook() -> bytes: """Строит xlsx с раскладкой ЦП: данные с СТРОКИ 8. Колонки (1-based): A=№, B=ЦП name, C=ПО, D=?, E=municipality, F=voltage, G=installed, H=current, I=reserve. """ wb = openpyxl.Workbook() ws = wb.active ws.title = "ЦП на 30.06.2026" for r in range(1, 8): ws.cell(row=r, column=1, value=f"header-{r}") # Строка 8 — первая строка данных. ws.append([1, "ПС 110/10 Уктус", "ПО-1", "", "Екатеринбург", "110/10", 40.0, 25.0, 15.0]) buf = io.BytesIO() wb.save(buf) return buf.getvalue() def test_load_cp_reserves_row_offset_and_norm_match() -> None: """Данные с строки 8; UPDATE по sc_name_norm; matched считает rowcount.""" db = _FakeSession(scalar_value=None, rowcount=1) # UPDATE matched 1 row result = rr.load_cp_reserves(db, _build_cp_workbook(), "цп на 30.06.2026.xlsx") assert result["rows"] == 1, result assert result["matched"] == 1 assert result["unmatched"] == 0 params = db.calls[0][1] # sc_name_norm совпадает с нормализацией голого имени «Уктус». assert params["name_norm"] == rw.normalize_sc_name("Уктус") == "уктус" assert params["installed"] == 40.0 assert params["reserve"] == 15.0 assert params["asof"] is not None and params["asof"].isoformat() == "2026-06-30" def test_load_cp_reserves_unmatched_counted() -> None: """ЦП без совпадения в power_supply_centers (rowcount=0) → unmatched, не падаем.""" db = _FakeSession(scalar_value=None, rowcount=0) # UPDATE matched 0 rows result = rr.load_cp_reserves(db, _build_cp_workbook(), "цп на 30.06.2026.xlsx") assert result["matched"] == 0 assert result["unmatched"] == 1 # ── fetch_disclosure_links (прод-инцидент 2026-07-02: URL-энкоженные href) ──── def test_fetch_disclosure_links_decodes_urlencoded_hrefs(monkeypatch): """href'ы на странице раскрытия URL-энкоженные (кириллица как %D0%A1...) — фильтр обязан работать по декодированному имени и выбирать свежайший файл. Пермский файл отфильтровывается, из двух ЦП-дат берётся поздняя.""" from urllib.parse import quote, unquote base = "/public/upload/Documents/2026/Transmission/" cp_tpl = ( "Информация о наличии объема свободной мощности по ЦП ПАО «Россети Урал» " "на территории {region} (на {d}).xlsx" ) tp_name = ( "Информация о наличии свободной мощности для ТП по ПС и РП напряжением " "ниже 35 кВ филиала Свердловэнерго на 30.06.2026.xlsx" ) hrefs = [ base + quote(cp_tpl.format(region="Свердловской области", d="30.03.2026")), base + quote(cp_tpl.format(region="Свердловской области", d="30.06.2026")), base + quote(cp_tpl.format(region="Пермского края", d="30.06.2026")), base + quote(tp_name), ] html = "".join(f'x' for h in hrefs) class _Resp: status_code = 200 text = html def raise_for_status(self): return None monkeypatch.setattr( "app.services.site_finder.rosseti_reserve_loader.httpx.get", lambda *a, **k: _Resp(), ) links = rr.fetch_disclosure_links() assert "30.06.2026" in unquote(links["cp"]) assert "Пермск" not in unquote(links["cp"]) assert "%D0" in links["cp"] # скачиваем по СЫРОМУ (энкоженному) href assert "Свердловэнерго" in unquote(links["tp_rp"])