270 lines
14 KiB
Python
270 lines
14 KiB
Python
"""Тесты парсера реестра КРТ-площадок ЕКБ (#1060).
|
||
|
||
Все тесты — без живой сети.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import textwrap
|
||
|
||
import pytest
|
||
|
||
from app.services.scrapers.ekb_krt_registry import parse_krt_page
|
||
|
||
# ── Фикстура: реальный фрагмент блока площадки 2 (Донбасская — Брусника) ────
|
||
# Вставлен из krt_page_raw.html (beksburg.rf/дляработы/гиз/крт).
|
||
# Охватывает обе цены аукциона, договор, застройщика, 2 постановления ПАГЕ.
|
||
_BRUSNIKA_BLOCK = textwrap.dedent(
|
||
"""\
|
||
<h2><strong>2022 год</strong></h2>
|
||
<p> <strong>2. КРТ жилой застройки в квартале улицы Донбасской – \
|
||
переулков Черниговского – Суворовского:</strong></p>
|
||
<p><strong><img class="js-has-preview img-float-left" \
|
||
title="2.	КРТ жилой застройки" src="/media/gallery/fe.jpg" width="100%" /></strong></p>
|
||
<p>Площадь территории: 2,79 га;<br />Градостроительный потенциал: 76 650 кв. м;<br />\
|
||
Всего многоквартирных домов: 10;<br />\
|
||
3. Издано Постановление Администрации города Екатеринбурга от 10.03.2022 № 621 \
|
||
«О комплексном развитии территории жилой застройки в квартале улицы Донбасской»;\
|
||
<br />\
|
||
4. Постановление Администрации города Екатеринбурга от 19.05.2022 № 1334 \
|
||
«О проведении аукциона»;<br />\
|
||
5. Аукцион:<br />Начальная стоимость: 641 018,64 рублей;<br />\
|
||
Цена по результатам торгов: 671 018 рублей.<br />\
|
||
6. Заключен договор о комплексном развитии территории жилой застройки от 10.08.2022 \
|
||
№ 1-2/21.7-14 (застройщик – ООО «Брусника. Специализированный застройщик»).\
|
||
<br />\
|
||
7. Постановление Администрации города Екатеринбурга от 28.12.2022 № 4051 \
|
||
«Об утверждении проекта планировки территории».</p>
|
||
<p><strong>Стадия реализации: строительство</strong></p>
|
||
"""
|
||
)
|
||
|
||
|
||
class TestParseBrusnikaBlock:
|
||
"""Тесты парсинга блока площадки Донбасская (Брусника)."""
|
||
|
||
@pytest.fixture(scope="class")
|
||
def site(self):
|
||
sites = parse_krt_page(_BRUSNIKA_BLOCK)
|
||
assert sites, "parse_krt_page вернул пустой список"
|
||
return sites[0]
|
||
|
||
def test_krt_kind(self, site):
|
||
assert site.krt_kind == "жилой застройки"
|
||
|
||
def test_developer(self, site):
|
||
assert site.developer_name is not None
|
||
assert "Брусника" in site.developer_name
|
||
|
||
def test_auction_start_price(self, site):
|
||
"""641 018,64 → 641018.64"""
|
||
assert site.auction_start_price == pytest.approx(641018.64, rel=1e-4)
|
||
|
||
def test_auction_final_price(self, site):
|
||
"""671 018 → 671018.0"""
|
||
assert site.auction_final_price == pytest.approx(671018.0, rel=1e-4)
|
||
|
||
def test_contract_number(self, site):
|
||
assert site.contract_number == "1-2/21.7-14"
|
||
|
||
def test_contract_date(self, site):
|
||
from datetime import date
|
||
|
||
assert site.contract_date == date(2022, 8, 10)
|
||
|
||
def test_paga_decisions_at_least_one(self, site):
|
||
assert len(site.paga_decisions) >= 1
|
||
# первое постановление — 10.03.2022 № 621
|
||
dates = [d["date"] for d in site.paga_decisions]
|
||
assert "10.03.2022" in dates
|
||
|
||
def test_area_ha(self, site):
|
||
assert site.area_ha == pytest.approx(2.79, rel=1e-3)
|
||
|
||
def test_gradpotential(self, site):
|
||
# «76 650 кв. м» → 76650.0
|
||
assert site.gradpotential_sqm == pytest.approx(76650.0, rel=1e-3)
|
||
|
||
def test_mkd_count(self, site):
|
||
assert site.mkd_count == 10
|
||
|
||
def test_site_name_contains_number(self, site):
|
||
assert "2." in site.site_name
|
||
assert "Донбасской" in site.site_name
|
||
|
||
def test_raw_block_nonempty(self, site):
|
||
assert len(site.raw_block) > 50
|
||
|
||
|
||
class TestNumericNormalization:
|
||
"""Тесты числовой нормализации (NBSP, запятая как десятичный разделитель)."""
|
||
|
||
def test_nbsp_price(self):
|
||
"""«641\xa0018,64» → 641018.64"""
|
||
from app.services.scrapers.ekb_krt_registry import _normalize_num
|
||
|
||
assert _normalize_num("641\xa0018,64") == pytest.approx(641018.64, rel=1e-4)
|
||
|
||
def test_space_price(self):
|
||
"""«671 018» → 671018.0"""
|
||
from app.services.scrapers.ekb_krt_registry import _normalize_num
|
||
|
||
assert _normalize_num("671 018") == pytest.approx(671018.0)
|
||
|
||
def test_plain_decimal(self):
|
||
from app.services.scrapers.ekb_krt_registry import _normalize_num
|
||
|
||
assert _normalize_num("3,65") == pytest.approx(3.65)
|
||
|
||
def test_invalid_returns_none(self):
|
||
from app.services.scrapers.ekb_krt_registry import _normalize_num
|
||
|
||
assert _normalize_num("нет данных") is None
|
||
|
||
|
||
class TestEmptyAndGarbage:
|
||
"""Пустой / мусорный HTML → []."""
|
||
|
||
def test_empty_string(self):
|
||
assert parse_krt_page("") == []
|
||
|
||
def test_whitespace_only(self):
|
||
assert parse_krt_page(" \n\t ") == []
|
||
|
||
def test_irrelevant_html(self):
|
||
assert parse_krt_page("<html><body><p>Привет</p></body></html>") == []
|
||
|
||
def test_no_krt_section(self):
|
||
html = "<html><body><h2>Другой раздел</h2><p>Текст</p></body></html>"
|
||
assert parse_krt_page(html) == []
|
||
|
||
|
||
# ── Синтетический блок: площадка без застройщика / договора / цен ─────────────
|
||
# Имитирует площадки «в работе» (только geography + технические параметры).
|
||
# Не требует локального дампа — всегда исполняется в CI.
|
||
_BABUSHKINA_BLOCK = textwrap.dedent(
|
||
"""\
|
||
<h2><strong>2024 год</strong></h2>
|
||
<p> <strong>1. КРТ жилой застройки в районе ул. Бабушкина:</strong></p>
|
||
<p>Площадь территории: 5,10 га;<br />\
|
||
Градостроительный потенциал: 120 000 кв. м;<br />\
|
||
Всего многоквартирных домов: 18;<br />\
|
||
3. Издано Постановление Администрации города Екатеринбурга от 15.01.2024 № 42 \
|
||
«О комплексном развитии территории».</p>
|
||
<p><strong>Стадия реализации: проектирование</strong></p>
|
||
"""
|
||
)
|
||
|
||
|
||
class TestParseBabushkinaBlock:
|
||
"""Синтетический тест: площадка без застройщика, договора и цен аукциона.
|
||
|
||
Не зависит от локального дампа — всегда запускается в CI.
|
||
"""
|
||
|
||
@pytest.fixture(scope="class")
|
||
def site(self):
|
||
sites = parse_krt_page(_BABUSHKINA_BLOCK)
|
||
assert sites, "parse_krt_page вернул пустой список для синтетического блока"
|
||
return sites[0]
|
||
|
||
def test_parses_exactly_one_site(self):
|
||
"""Синтетический блок содержит ровно одну площадку."""
|
||
assert len(parse_krt_page(_BABUSHKINA_BLOCK)) == 1
|
||
|
||
def test_developer_is_none(self, site):
|
||
"""Застройщик не указан → None."""
|
||
assert site.developer_name is None
|
||
|
||
def test_contract_number_is_none(self, site):
|
||
"""Договор не заключён → None."""
|
||
assert site.contract_number is None
|
||
|
||
def test_area_ha_filled(self, site):
|
||
"""Площадь территории заполнена."""
|
||
assert site.area_ha == pytest.approx(5.10, rel=1e-3)
|
||
|
||
def test_gradpotential_filled(self, site):
|
||
"""Градостроительный потенциал заполнен."""
|
||
assert site.gradpotential_sqm == pytest.approx(120000.0, rel=1e-3)
|
||
|
||
def test_district_filled(self, site):
|
||
"""Район извлечён из названия блока."""
|
||
assert site.district is not None
|
||
assert "Бабушкина" in site.district
|
||
|
||
def test_krt_kind(self, site):
|
||
assert site.krt_kind == "жилой застройки"
|
||
|
||
|
||
# ── Многоблочная страница: два года, три площадки, повторяющийся номер ────────
|
||
#
|
||
# Здесь до #2778 стоял TestMultiSiteCountFromDump, читавший
|
||
# `.playwright-mcp/ekb-docs/krt_page_raw.html`. Дампа нет ни в репозитории, ни у
|
||
# кого-либо на диске, ни на проде: `ekb_krt_sites.raw_block` хранит не HTML, а
|
||
# уже очищенный текст (`block_text[:4000]` в ekb_krt_registry.py:338), поэтому
|
||
# восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ.
|
||
#
|
||
# Свойство, ради которого они писались, от дампа не зависит и выражается
|
||
# страницей из блоков, которые уже лежат в этом файле:
|
||
# • несколько <h2>-секций с годами разбираются в одном проходе;
|
||
# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ,
|
||
# поэтому «2.» встречается дважды — и site_name всё равно обязан быть
|
||
# уникальным (номер сам по себе ключом не является);
|
||
# • блоки лежат внутри контейнера <div> — это ветка `_extract_krt_section`
|
||
# через `find_parent`, которой одиночные блоки выше не касаются вовсе.
|
||
#
|
||
# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778):
|
||
# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора
|
||
# реального документа, синтетической страницей оно не заменяется. На проде
|
||
# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но
|
||
# автоматической проверки этого больше нет — она вернётся, когда в репозитории
|
||
# появится образец страницы.
|
||
_MULTISITE_PAGE = (
|
||
"<div class='content'>"
|
||
+ _BRUSNIKA_BLOCK
|
||
+ _BABUSHKINA_BLOCK
|
||
+ textwrap.dedent(
|
||
"""\
|
||
<p> <strong>2. КРТ нежилой застройки в районе ул. Краснофлотцев:</strong></p>
|
||
<p>Площадь территории: 3,65 га;<br />\
|
||
Градостроительный потенциал: 117 640 кв. м;<br />\
|
||
Всего многоквартирных домов: 13.</p>
|
||
"""
|
||
)
|
||
+ "</div>"
|
||
)
|
||
|
||
|
||
class TestMultiSitePage:
|
||
"""Разбор страницы из нескольких блоков — без локального дампа."""
|
||
|
||
@pytest.fixture(scope="class")
|
||
def sites(self):
|
||
return parse_krt_page(_MULTISITE_PAGE)
|
||
|
||
def test_all_three_sites_parsed(self, sites):
|
||
"""Три блока в двух годовых секциях → три площадки."""
|
||
assert len(sites) == 3, f"Ожидалось 3 площадки, получено {len(sites)}: {sites}"
|
||
|
||
def test_no_duplicate_site_names(self, sites):
|
||
"""Номер «2.» встречается дважды (2022 и 2024) — имена всё равно уникальны."""
|
||
names = [s.site_name for s in sites]
|
||
assert sum(n.startswith("2.") for n in names) == 2, names
|
||
assert len(names) == len(set(names)), f"Дубликаты site_name: {names}"
|
||
|
||
def test_blocks_do_not_leak_into_each_other(self, sites):
|
||
"""Границы блоков соблюдены: договор и застройщик остались у своей площадки.
|
||
|
||
Регресс, который этим ловится: если block_end съезжает, «Брусника» и
|
||
договор 1-2/21.7-14 из первого блока прилипают ко второму и третьему.
|
||
"""
|
||
with_developer = [s for s in sites if s.developer_name]
|
||
assert len(with_developer) == 1, [s.developer_name for s in sites]
|
||
assert "Брусника" in with_developer[0].developer_name
|
||
assert [s.contract_number for s in sites].count("1-2/21.7-14") == 1
|
||
|
||
def test_kinds_are_read_per_block(self, sites):
|
||
"""Третий блок — «нежилой застройки», не должен унаследовать вид соседа."""
|
||
kinds = sorted(s.krt_kind for s in sites if s.krt_kind)
|
||
assert kinds == ["жилой застройки", "жилой застройки", "нежилой застройки"], kinds
|