gendesign/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py
lekss361 1e53416277
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m27s
Deploy / deploy (push) Successful in 1m21s
feat(sf): ППТ/ПМТ ТЭП-парсер (Табл.11/12/13) → ekb_ppt_tep MVP (#1133)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 11:54:40 +00:00

418 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты парсера ТЭП из ППТ/ПМТ пояснительных записок (#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