""" Автотесты для main.py. Запуск: python3 -m pytest test_main.py -v Браузер и настоящий ФИАС не нужны — всё замокано. """ from __future__ import annotations import os import re from pathlib import Path from unittest.mock import MagicMock, patch, call import pytest import openpyxl import main from main import ( _normalize_kn, parse_pdf_extract, rename_extract, process_all, FIASSearcher, ) from selenium.common.exceptions import TimeoutException, WebDriverException # ───────────────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────────────── def make_xlsx(tmp_path: Path, rows, headers=("КН", "Вид ОН")) -> str: path = tmp_path / "input.xlsx" wb = openpyxl.Workbook() ws = wb.active ws.append(list(headers)) for row in rows: ws.append(list(row)) wb.save(str(path)) return str(path) def get_result_rows(output_dir: Path) -> list: """Читает первый Результаты_ФИАС_*.xlsx из папки сессии (без заголовка).""" files = list(output_dir.rglob("Результаты_ФИАС_*.xlsx")) assert files, "Результирующий xlsx не найден" wb = openpyxl.load_workbook(str(files[0])) return list(wb.active.iter_rows(min_row=2, values_only=True)) def fake_searcher(search_map: dict | None = None, *, side_effect=None): """Возвращает мок FIASSearcher с заданными результатами поиска.""" m = MagicMock() if side_effect is not None: m.search.side_effect = side_effect elif search_map is not None: m.search.side_effect = lambda kn: search_map.get(kn) else: m.search.return_value = None return m def make_pdf_mock(text: str): """Мок pdfplumber.open(path) как контекстный менеджер с одной страницей.""" page = MagicMock() page.extract_text.return_value = text ctx = MagicMock() ctx.__enter__ = lambda s: ctx ctx.__exit__ = MagicMock(return_value=False) ctx.pages = [page] return ctx # ───────────────────────────────────────────────────────────────────────────── # _normalize_kn # ───────────────────────────────────────────────────────────────────────────── class TestNormalizeKn: def test_already_clean(self): assert _normalize_kn("77:01:0001001:1") == "77:01:0001001:1" def test_spaces_around_colons(self): assert _normalize_kn("77 : 01 : 0001001 : 1") == "77:01:0001001:1" def test_tabs_around_colons(self): assert _normalize_kn("77\t:\t01\t:\t0001001\t:\t1") == "77:01:0001001:1" def test_mixed_whitespace(self): assert _normalize_kn(" 77 :01: 0001001:1 ") == "77:01:0001001:1" def test_trailing_newline(self): assert _normalize_kn("77:01:0001001:1\n") == "77:01:0001001:1" def test_none_becomes_string_none(self): # raw=None приходит если ячейка пустая, но это отфильтровывается раньше assert _normalize_kn("None") == "None" # ───────────────────────────────────────────────────────────────────────────── # parse_pdf_extract # ───────────────────────────────────────────────────────────────────────────── class TestParsePdfExtract: UUID = "12345678-abcd-abcd-abcd-123456789abc" def _parse(self, tmp_path, text): path = str(tmp_path / "test.pdf") Path(path).touch() with patch("pdfplumber.open", return_value=make_pdf_mock(text)): return parse_pdf_extract(path, log=lambda _: None) def test_date_near_keyword(self, tmp_path): date, _ = self._parse(tmp_path, "дата выписки 25.03.2024 прочее") assert date == "25.03.2024" def test_date_near_vipiska_keyword(self, tmp_path): date, _ = self._parse(tmp_path, "выписка от 01.01.2023") assert date == "01.01.2023" def test_date_fallback_first_in_text(self, tmp_path): date, _ = self._parse(tmp_path, "Документ 11.11.2021 без ключевых слов") assert date == "11.11.2021" def test_date_prefers_keyword_over_first(self, tmp_path): # "01.01.2000" стоит до ключевого слова, "25.03.2024" — рядом с ним date, _ = self._parse(tmp_path, "01.01.2000 текст дата документа 25.03.2024") assert date == "25.03.2024" def test_uuid_near_keyword(self, tmp_path): _, num = self._parse(tmp_path, f"номер идентификатор {self.UUID}") assert num == self.UUID def test_uuid_fallback(self, tmp_path): _, num = self._parse(tmp_path, f"Сведения {self.UUID} конец") assert num == self.UUID def test_uuid_case_insensitive(self, tmp_path): uuid_upper = self.UUID.upper() _, num = self._parse(tmp_path, f"Идентификатор {uuid_upper}") assert num is not None assert num.lower() == self.UUID.lower() def test_nothing_found(self, tmp_path): date, num = self._parse(tmp_path, "Текст без дат и UUID") assert date is None assert num is None def test_both_found(self, tmp_path): date, num = self._parse(tmp_path, f"дата 31.12.2023 номер {self.UUID}") assert date == "31.12.2023" assert num == self.UUID def test_corrupt_pdf_returns_none_none(self, tmp_path): path = str(tmp_path / "bad.pdf") Path(path).write_bytes(b"garbage") with patch("pdfplumber.open", side_effect=Exception("corrupt")): date, num = parse_pdf_extract(path, log=lambda _: None) assert date is None assert num is None def test_empty_text_returns_none_none(self, tmp_path): date, num = self._parse(tmp_path, "") assert date is None assert num is None def test_multipage_text_joined(self, tmp_path): """Текст со всех страниц объединяется.""" page1, page2 = MagicMock(), MagicMock() page1.extract_text.return_value = "стр 1" page2.extract_text.return_value = f"дата 15.06.2022 номер {self.UUID}" ctx = MagicMock() ctx.__enter__ = lambda s: ctx ctx.__exit__ = MagicMock(return_value=False) ctx.pages = [page1, page2] path = str(tmp_path / "multi.pdf") Path(path).touch() with patch("pdfplumber.open", return_value=ctx): date, num = parse_pdf_extract(path, log=lambda _: None) assert date == "15.06.2022" assert num == self.UUID # ───────────────────────────────────────────────────────────────────────────── # rename_extract # ───────────────────────────────────────────────────────────────────────────── class TestRenameExtract: KN = "77:01:0001001:1" DATE = "25.03.2024" EXPECTED_STEM = "ГАР_77_01_0001001_1_25.03.2024" def _make_src(self, tmp_path): src = tmp_path / "_tmp.pdf" src.write_bytes(b"%PDF-test") return str(src) def test_normal_rename(self, tmp_path): src = self._make_src(tmp_path) result = rename_extract(src, self.KN, self.DATE, str(tmp_path)) assert Path(result).exists() assert Path(result).name == f"{self.EXPECTED_STEM}.pdf" assert not Path(src).exists() def test_colons_replaced_with_underscores(self, tmp_path): src = self._make_src(tmp_path) result = rename_extract(src, self.KN, self.DATE, str(tmp_path)) assert ":" not in Path(result).name def test_conflict_appends_counter(self, tmp_path): (tmp_path / f"{self.EXPECTED_STEM}.pdf").write_bytes(b"existing") src = self._make_src(tmp_path) result = rename_extract(src, self.KN, self.DATE, str(tmp_path)) assert Path(result).name == f"{self.EXPECTED_STEM}_1.pdf" def test_multiple_conflicts_increment(self, tmp_path): for suffix in ("", "_1", "_2"): (tmp_path / f"{self.EXPECTED_STEM}{suffix}.pdf").write_bytes(b"x") src = self._make_src(tmp_path) result = rename_extract(src, self.KN, self.DATE, str(tmp_path)) assert Path(result).name == f"{self.EXPECTED_STEM}_3.pdf" def test_no_date_uses_today(self, tmp_path): src = self._make_src(tmp_path) result = rename_extract(src, self.KN, None, str(tmp_path)) assert Path(result).exists() assert re.search(r"\d{2}\.\d{2}\.\d{4}", Path(result).name) # ───────────────────────────────────────────────────────────────────────────── # process_all — интеграционные тесты с замоканным FIASSearcher # ───────────────────────────────────────────────────────────────────────────── FOUND_DATA = { "full_name": "ул. Тестовая, 1", "status": "Актуальный", "obj_type": "Здание", "fias_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "pdf_url": None, } class TestProcessAll: def _run(self, tmp_path, rows, headers=("КН", "Вид ОН"), *, mock_searcher=None): xlsx = make_xlsx(tmp_path, rows, headers) if mock_searcher is None: mock_searcher = fake_searcher() with patch("main.FIASSearcher", return_value=mock_searcher): process_all(xlsx, str(tmp_path), log=lambda _: None) return get_result_rows(tmp_path) # ── столбцы ────────────────────────────────────────────────────────────── def test_missing_kn_column_returns_early(self, tmp_path): xlsx = make_xlsx(tmp_path, [("val",)], headers=("НЕТ",)) mock_s = fake_searcher() logs = [] with patch("main.FIASSearcher", return_value=mock_s): process_all(xlsx, str(tmp_path), log=logs.append) mock_s.search.assert_not_called() assert any("не найдена" in l for l in logs) def test_kn_column_case_insensitive(self, tmp_path): # "Кадастровый номер" (смешанный регистр) rows = self._run(tmp_path, [("77:01:0001001:1",)], headers=("Кадастровый номер",)) assert len(rows) == 1 # ── нормальные сценарии ─────────────────────────────────────────────────── def test_kn_found_writes_correct_row(self, tmp_path): ms = fake_searcher({"77:01:0001001:1": FOUND_DATA}) rows = self._run(tmp_path, [("77:01:0001001:1", "Здание")], mock_searcher=ms) assert rows[0][0] == "77:01:0001001:1" assert rows[0][3] == "Актуальный" assert rows[0][4] == "ул. Тестовая, 1" assert rows[0][2] == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" def test_kn_not_found_writes_absent_row(self, tmp_path): rows = self._run(tmp_path, [("99:99:9999999:9", None)]) assert rows[0][2] == "отсутствует" assert rows[0][3] == "отсутствует" def test_multiple_kns_all_processed(self, tmp_path): ms = fake_searcher({ "77:01:0001001:1": FOUND_DATA, "77:01:0001001:2": None, }) rows = self._run( tmp_path, [("77:01:0001001:1", None), ("77:01:0001001:2", None)], mock_searcher=ms, ) assert len(rows) == 2 def test_empty_xlsx_produces_no_data_rows(self, tmp_path): ms = fake_searcher() rows = self._run(tmp_path, [], mock_searcher=ms) assert rows == [] ms.search.assert_not_called() # Output file must exist even with zero entries assert list((tmp_path).rglob("Результаты_ФИАС_*.xlsx")) # ── дедупликация ────────────────────────────────────────────────────────── def test_duplicate_kn_searched_once(self, tmp_path): # Each unique KN gets up to 2 attempts (retry logic). # A duplicate must not add extra attempts beyond that. ms = fake_searcher({"77:01:0001001:1": FOUND_DATA}) # returns on first try self._run( tmp_path, [("77:01:0001001:1", None), ("77:01:0001001:1", None)], mock_searcher=ms, ) # Exactly 1 call: found on first attempt, duplicate skipped entirely assert ms.search.call_count == 1 def test_duplicate_kn_writes_one_row(self, tmp_path): ms = fake_searcher() rows = self._run( tmp_path, [("77:01:0001001:1", None), ("77:01:0001001:1", None)], mock_searcher=ms, ) assert len(rows) == 1 # ── нормализация КН ─────────────────────────────────────────────────────── def test_spaces_in_kn_normalized_before_search(self, tmp_path): # search() is called with the normalised KN (spaces stripped around colons). # It may be called twice due to retry logic; assert_any_call covers both. ms = fake_searcher() self._run(tmp_path, [("77 : 01 : 0001001 : 1", None)], mock_searcher=ms) ms.search.assert_any_call("77:01:0001001:1") # Must never be called with the raw un-normalised form for c in ms.search.call_args_list: assert c.args[0] == "77:01:0001001:1" def test_normalized_kn_written_to_output(self, tmp_path): ms = fake_searcher() rows = self._run(tmp_path, [("77 : 01 : 0001001 : 1", None)], mock_searcher=ms) assert rows[0][0] == "77:01:0001001:1" # ── ошибки при поиске ───────────────────────────────────────────────────── def test_webdriver_error_writes_exactly_one_error_row(self, tmp_path): """Регрессия: двойная запись 'ошибка' + 'отсутствует' для одного КН.""" ms = fake_searcher(side_effect=WebDriverException("crash")) rows = self._run(tmp_path, [("77:01:0001001:1", None)], mock_searcher=ms) assert len(rows) == 1 assert rows[0][2] == "ошибка" # Убеждаемся что нет второй строки «отсутствует» statuses = [r[2] for r in rows] assert "отсутствует" not in statuses def test_generic_exception_writes_exactly_one_error_row(self, tmp_path): ms = fake_searcher(side_effect=RuntimeError("unexpected")) rows = self._run(tmp_path, [("77:01:0001001:1", None)], mock_searcher=ms) assert len(rows) == 1 assert rows[0][2] == "ошибка" def test_error_kn_still_marked_done(self, tmp_path): """После ошибки дубликат того же КН не должен искаться повторно.""" ms = fake_searcher(side_effect=WebDriverException("crash")) self._run( tmp_path, [("77:01:0001001:1", None), ("77:01:0001001:1", None)], mock_searcher=ms, ) assert ms.search.call_count == 1 # ── повтор поиска ───────────────────────────────────────────────────────── def test_retry_on_none_result(self, tmp_path): """Первая попытка даёт None, вторая — данные: КН должен быть найден.""" results = [None, FOUND_DATA] ms = fake_searcher(side_effect=lambda kn: results.pop(0)) rows = self._run(tmp_path, [("77:01:0001001:1", None)], mock_searcher=ms) assert rows[0][3] == "Актуальный" assert ms.search.call_count == 2 def test_both_retries_none_marks_absent(self, tmp_path): ms = fake_searcher(side_effect=lambda kn: None) rows = self._run(tmp_path, [("77:01:0001001:1", None)], mock_searcher=ms) assert rows[0][2] == "отсутствует" assert ms.search.call_count == 2 # ───────────────────────────────────────────────────────────────────────────── # FIASSearcher — парсинг строк таблицы результатов # ───────────────────────────────────────────────────────────────────────────── def make_cell(text: str) -> MagicMock: c = MagicMock() c.text = text return c def make_tr(cells_text: list[str], inner_html: str = "") -> MagicMock: cells = [make_cell(t) for t in cells_text] row = MagicMock() row.find_elements.side_effect = lambda by, tag: cells if tag == "td" else [] row.get_attribute.return_value = inner_html return row def make_th_row() -> MagicMock: """Заголовочная строка —