Post-фильтры парсера (по образцу ppt2018_22823): - Табл.11: отбрасываем строки без zone_name (преамбульные артефакты merged-cells давали 2 фантома при 12 реальных зонах). - Табл.13: dedup по (phase, composition, zone, area_ha) + фильтры короткого-без-зоны и нумерационного шума (~364 строк → ~30-40 реальных). Analyze-врезка: новый ppt_tep_lookup.parcel_ppt_tep — JOIN planning_projects ⋈ ekb_ppt_tep по doc_ref↔source_key/doc_full_name (best-effort, без FK). Wired в build_ird_analyze_block рядом с planning_projects/krt_requisites — DB-источники, graceful. Seed-URL остаётся placeholder с # VERIFY (ingest пропускает с WARNING). Tests: 27 в parser (3 новых), 7 lookup, 22 wiring (2 новых). All ruff/syntax green. Closes #1136
534 lines
20 KiB
Python
534 lines
20 KiB
Python
"""Тесты парсера ТЭП из ППТ/ПМТ пояснительных записок (#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
|