"""Тесты парсера ТЭП из ППТ/ПМТ пояснительных записок (#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 == [] def test_preamble_artifact_filtered_out(self) -> None: """Post-фильтр #1136: строки с № но без zone_name отбрасываются. Это «фантомные» строки преамбульного merged-cells шума (на ppt2018_22823 давали 2 артефакта при 12 реальных зонах). """ rows: list[list[str | None]] = [ ["№", "Наименование зоны", "Площадь", None], [None, None, "Сущ., га (%)", "Проект, га\n(%)"], # Артефакт: № есть, но zone_name пустой — должен отброситься. ["1", "", "15,41 га", "11,34 га"], # Артефакт: № есть, zone_name пустой (None) — должен отброситься. ["2", None, "0,24 га", "0,25 га"], # Реальная зона: остаётся. ["3", "Зона жилой застройки", "10,0 га", "12,0 га"], # Итого: остаётся (zone_name пуст, но is_total). ["Итого:", None, "25,65 га", "23,59 га"], ] result = _parse_table11(rows) zone_names = [r["zone_name"] for r in result] # Реальная зона должна быть в результате assert "Зона жилой застройки" in zone_names # Артефакты (zone_name пуст, не is_total) отброшены non_total_no_zone = [ r for r in result if not r["zone_name"] and not r.get("is_total") ] assert non_total_no_zone == [] # Итого-строка осталась assert any(r.get("is_total") for r in 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([]) == [] def test_dedup_removes_continuation_noise(self) -> None: """Post-фильтр #1136: дубликаты merged-cells continuation схлопываются. На ppt2018_22823 364 «строк» сворачивались в ~30-40 реальных. Проверяем dedup по (phase, composition, zone, area_ha_norm). """ # Реальная строка и три её continuation-копии (merged-cells даёт повторы). real_row: list[str | None] = [ "I этап\n2018-2021", "", "", "Многофункциональная ледовая арена", None, None, None, None, None, None, None, None, "", "Зона объектов спорта", None, "4,97", None, "Внебюджетные", None, "", ] rows: list[list[str | None]] = [ _TABLE13_ROWS[0], _TABLE13_ROWS[1], real_row, real_row, # continuation-дубль 1 real_row, # continuation-дубль 2 real_row, # continuation-дубль 3 ] result = _parse_table13(rows) # Все 4 копии должны схлопнуться в 1. matching = [ r for r in result if r["zone"] == "Зона объектов спорта" and r.get("area_ha_num") == pytest.approx(4.97) ] assert len(matching) == 1 def test_short_noise_rows_filtered(self) -> None: """Post-фильтр #1136: composition < 4 символов без zone — шум merged-cells.""" rows: list[list[str | None]] = [ _TABLE13_ROWS[0], _TABLE13_ROWS[1], # Шум: composition «-», без zone — должен отброситься [None, "-"] + [None] * 18, # Шум: composition «1», без zone — должен отброситься [None, "1"] + [None] * 18, # Реальная строка [ "I этап", "", "Жилой комплекс", None, None, None, None, None, None, None, None, None, None, "Зона Ж-1", None, "5,0", None, "Бюджет", None, "", ], ] result = _parse_table13(rows) compositions = [r["composition"] for r in result] assert "-" not in compositions # Одиночная «1» должна отлететь как номер-осколок # (real_row даёт composition='Жилой комплекс'). assert any("Жилой" in c for c in compositions) # ── Тест 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