"""Тесты парсера ТЭП из ППТ/ПМТ пояснительных записок (#1085). Синтетические тесты: pdfplumber мокируется; реальный PDF-тест — отдельный класс с @pytest.mark.skipif (skip в CI, запуск вручную). """ from __future__ import annotations from pathlib import Path from unittest.mock import MagicMock, patch import pytest from app.services.scrapers.ekb_ppt_tep_parser import ( PptTep, _parse_table11, _parse_table12, _parse_table13, parse_ppt_tep, ) # ── Вспомогательные фикстуры ───────────────────────────────────────────────── # Минимальные данные Табл.11 (баланс территории) _TABLE11_ROWS: list[list[str | None]] = [ ["№", "Наименование зоны", "Площадь", None], [None, None, "Сущ., га (%)", "Проект, га\n(%)"], ["1", "Многофункциональная зона", "15,41 га\n(42,61 %)", "11,34 га\n(31,36 %)"], ["2", "Зона объектов здравоохранения", "0,24 га\n(0,66 %)", "0,25 га\n(0,69 %)"], ["Итого:", None, "36,16 га\n(100 %)", "36,16 га\n(100 %)"], ] # Минимальные данные Табл.12 (ТЭП) _TABLE12_ROWS: list[list[str | None]] = [ [ "№", "Наименование показателей", "Единица\nизмерения", "Современное\nсостояние", "Расчётный\nсрок", ], ["1", "Территория", None, None, None], ["", "Площадь проектируемой территории", "га", "36,1", "36,16"], ["2", "Жилищный фонд", None, None, None], ["2.1", "Общая площадь жилых помещений", "тыс. кв. м", "", "120,5"], ] # Минимальные данные Табл.13 (очередность) _TABLE13_ROWS: list[list[str | None]] = [ # Заголовок [ "Номер\nочереди\nстроит-ва", "Состав этапа строительства", None, None, None, None, None, None, None, None, None, None, None, "Зона планируемого\nразмещения", None, "Площадь зон, га", None, "Источник\nфинансирования", None, "Программа", ], # Строка-нумерация «1 2 4 5 6 7» [ "1", "2", None, None, None, None, None, None, None, None, None, None, None, "4", None, "5", None, "6", None, "7", ], # Реальные строки [ "I этап\n2018-2021", "", None, None, None, None, None, None, None, None, None, "Объекты капитального строительства", None, None, None, None, "", None, None, None, ], [ None, "", "", "Многофункциональная ледовая арена", None, None, None, None, None, None, None, None, "", "Зона объектов спорта", None, "4,97", None, "Внебюджетные", None, "", ], ] # ── Тесты _parse_table11 ────────────────────────────────────────────────────── class TestParseTable11: def test_returns_list(self) -> None: result = _parse_table11(_TABLE11_ROWS) assert isinstance(result, list) def test_correct_row_count(self) -> None: """Должны получить 2 зоны + 1 Итого-строку.""" result = _parse_table11(_TABLE11_ROWS) assert len(result) == 3 def test_zone_name_present(self) -> None: result = _parse_table11(_TABLE11_ROWS) zone_names = [r["zone_name"] for r in result] assert any("Многофункциональная" in n for n in zone_names) def test_numeric_parsed(self) -> None: result = _parse_table11(_TABLE11_ROWS) row1 = next(r for r in result if r["no"] == "1") assert row1["existing_ha_num"] == pytest.approx(15.41, abs=0.01) assert row1["project_ha_num"] == pytest.approx(11.34, abs=0.01) def test_itogo_row_flagged(self) -> None: result = _parse_table11(_TABLE11_ROWS) totals = [r for r in result if r.get("is_total")] assert len(totals) == 1 def test_empty_input_returns_empty(self) -> None: assert _parse_table11([]) == [] def test_missing_table_returns_empty(self) -> None: """Строки без полезного содержимого → пустой результат.""" garbage = [["", None, None, None], [None, None, None, None]] result = _parse_table11(garbage) assert result == [] # ── Тесты _parse_table12 ────────────────────────────────────────────────────── class TestParseTable12: def test_returns_list(self) -> None: result = _parse_table12(_TABLE12_ROWS) assert isinstance(result, list) def test_header_skipped(self) -> None: result = _parse_table12(_TABLE12_ROWS) # Первая строка с «№»/«Наименование показателей» должна быть пропущена no_values = [r["no"] for r in result] assert "№" not in no_values def test_indicator_present(self) -> None: result = _parse_table12(_TABLE12_ROWS) indicators = [r["indicator"] for r in result] assert any("Территория" in i for i in indicators) def test_numeric_values(self) -> None: result = _parse_table12(_TABLE12_ROWS) area_row = next((r for r in result if "Площадь" in r["indicator"]), None) assert area_row is not None assert area_row["target"] == "36,16" def test_empty_input_returns_empty(self) -> None: assert _parse_table12([]) == [] # ── Тесты _parse_table13 ────────────────────────────────────────────────────── class TestParseTable13: def test_returns_list(self) -> None: result = _parse_table13(_TABLE13_ROWS) assert isinstance(result, list) def test_phase_extracted(self) -> None: result = _parse_table13(_TABLE13_ROWS) phases = [r["phase"] for r in result if r["phase"]] assert any("этап" in p.lower() or "2018" in p for p in phases) def test_zone_extracted(self) -> None: result = _parse_table13(_TABLE13_ROWS) zones = [r["zone"] for r in result if r["zone"]] assert any("спорт" in z.lower() or "Зона" in z for z in zones) def test_area_ha_num_parsed(self) -> None: result = _parse_table13(_TABLE13_ROWS) rows_with_area = [r for r in result if r.get("area_ha_num") is not None] assert len(rows_with_area) >= 1 areas = [r["area_ha_num"] for r in rows_with_area] assert any(abs(a - 4.97) < 0.01 for a in areas) def test_empty_input_returns_empty(self) -> None: assert _parse_table13([]) == [] # ── Тест parse_ppt_tep с mock pdfplumber ───────────────────────────────────── def _make_mock_page(text: str, tables: list[list[list[str | None]]]) -> MagicMock: """Создаёт mock pdfplumber Page с заданным текстом и таблицами.""" page = MagicMock() page.extract_text.return_value = text page.extract_tables.return_value = tables return page class TestParsePptTepMocked: """Тесты parse_ppt_tep с мокированным pdfplumber.open.""" def _build_mock_pdf(self) -> list[MagicMock]: """Собирает 3-страничный mock-PDF с Табл.11/12/13.""" # Страница 1: Таблица 11 (баланс) page11 = _make_mock_page( "Таблица 11\nБаланс территории", [_TABLE11_ROWS], ) # Страница 2: Таблица 12 (ТЭП) page12 = _make_mock_page( "Таблица 12\nОсновные технико-экономические показатели", [_TABLE12_ROWS], ) # Страница 3: Таблица 13 (очередность) page13 = _make_mock_page( "Таблица 13\nОчередность строительства", [_TABLE13_ROWS], ) return [page11, page12, page13] def test_returns_ppt_tep_instance(self) -> None: pages = self._build_mock_pdf() ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=ctx) ctx.__exit__ = MagicMock(return_value=False) ctx.pages = pages with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): result = parse_ppt_tep(b"fake-pdf-bytes") assert isinstance(result, PptTep) def test_zone_balance_not_empty(self) -> None: pages = self._build_mock_pdf() ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=ctx) ctx.__exit__ = MagicMock(return_value=False) ctx.pages = pages with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): result = parse_ppt_tep(b"fake-pdf-bytes") assert len(result.zone_balance) > 0, "zone_balance не должен быть пустым" def test_tep_not_empty(self) -> None: pages = self._build_mock_pdf() ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=ctx) ctx.__exit__ = MagicMock(return_value=False) ctx.pages = pages with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): result = parse_ppt_tep(b"fake-pdf-bytes") assert len(result.tep) > 0, "tep не должен быть пустым" def test_phasing_not_empty(self) -> None: pages = self._build_mock_pdf() ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=ctx) ctx.__exit__ = MagicMock(return_value=False) ctx.pages = pages with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): result = parse_ppt_tep(b"fake-pdf-bytes") assert len(result.phasing) > 0, "phasing не должен быть пустым" def test_missing_table13_returns_empty_phasing(self) -> None: """Если Табл.13 нет в PDF — phasing = [].""" pages_no13 = self._build_mock_pdf()[:2] # только стр. 1-2 ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=ctx) ctx.__exit__ = MagicMock(return_value=False) ctx.pages = pages_no13 with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): result = parse_ppt_tep(b"fake-pdf-bytes") assert result.phasing == [], "phasing должен быть [] если Табл.13 не найдена" def test_missing_all_tables_returns_empty_result(self) -> None: """PDF без таблиц → все поля [].""" page_empty = _make_mock_page("Пустая страница без таблиц", []) ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=ctx) ctx.__exit__ = MagicMock(return_value=False) ctx.pages = [page_empty] with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): result = parse_ppt_tep(b"fake-pdf-bytes") assert result.zone_balance == [] assert result.tep == [] assert result.phasing == [] def test_raw_tables_populated(self) -> None: """raw_tables заполняется данными о найденных таблицах.""" pages = self._build_mock_pdf() ctx = MagicMock() ctx.__enter__ = MagicMock(return_value=ctx) ctx.__exit__ = MagicMock(return_value=False) ctx.pages = pages with patch("app.services.scrapers.ekb_ppt_tep_parser.pdfplumber.open", return_value=ctx): result = parse_ppt_tep(b"fake-pdf-bytes") assert len(result.raw_tables) > 0 # Проверяем что raw_tables содержит page, table_index, rows first = result.raw_tables[0] assert "page" in first assert "table_index" in first assert "rows" in first # ── Тест на реальном PDF-образце (skip в CI) ───────────────────────────────── _REAL_PDF_PATH = Path( "C:/Users/user/source/repos/gendesign/.playwright-mcp/ekb-docs/ppt2018_22823_poyasnit_PPT.pdf" ) @pytest.mark.skipif( not _REAL_PDF_PATH.exists(), reason="Реальный PDF-образец отсутствует (только для локального запуска)", ) class TestParsePptTepRealPdf: """Интеграционные тесты на реальном образце ppt2018_22823.""" @pytest.fixture(scope="class") def result(self) -> PptTep: pdf_bytes = _REAL_PDF_PATH.read_bytes() return parse_ppt_tep(pdf_bytes) def test_zone_balance_has_rows(self, result: PptTep) -> None: """Табл.11 должна иметь ≥5 зон.""" n = len(result.zone_balance) assert n >= 5, f"Ожидалось ≥5 зон, получено {n}" def test_zone_balance_has_itogo(self, result: PptTep) -> None: """Итоговая строка присутствует в Табл.11.""" has_total = any( "итого" in r.get("zone_name", "").lower() or r.get("is_total") for r in result.zone_balance ) assert has_total, "Итого-строка не найдена в zone_balance" def test_tep_has_rows(self, result: PptTep) -> None: """Табл.12 должна иметь ≥10 показателей.""" assert len(result.tep) >= 10, f"Ожидалось ≥10 показателей, получено {len(result.tep)}" def test_phasing_has_rows(self, result: PptTep) -> None: """Табл.13 должна иметь ≥3 строки.""" n = len(result.phasing) assert n >= 3, f"Ожидалось ≥3 строки очередности, получено {n}" def test_phasing_has_area(self, result: PptTep) -> None: """Хотя бы одна строка очередности содержит площадь > 0.""" areas = [r["area_ha_num"] for r in result.phasing if r.get("area_ha_num")] assert areas, "Площади зон в Табл.13 не распарсились" assert max(areas) > 0 def test_raw_tables_not_empty(self, result: PptTep) -> None: """raw_tables содержит хотя бы 3 записи (по одной на таблицу).""" assert len(result.raw_tables) >= 3