"""Тесты парсера реестра КРТ-площадок ЕКБ (#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( """\

2022 год

 2. КРТ жилой застройки в квартале улицы Донбасской – \ переулков Черниговского – Суворовского:

Площадь территории: 2,79 га;
Градостроительный потенциал: 76 650 кв. м;
\ Всего многоквартирных домов: 10;
\ 3. Издано Постановление Администрации города Екатеринбурга от 10.03.2022 № 621 \ «О комплексном развитии территории жилой застройки в квартале улицы Донбасской»;\
\ 4. Постановление Администрации города Екатеринбурга от 19.05.2022 № 1334 \ «О проведении аукциона»;
\ 5. Аукцион:
Начальная стоимость: 641 018,64 рублей;
\ Цена по результатам торгов: 671 018 рублей.
\ 6. Заключен договор о комплексном развитии территории жилой застройки от 10.08.2022 \ № 1-2/21.7-14 (застройщик – ООО «Брусника. Специализированный застройщик»).\
\ 7. Постановление Администрации города Екатеринбурга от 28.12.2022 № 4051 \ «Об утверждении проекта планировки территории».

Стадия реализации: строительство

""" ) 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("

Привет

") == [] def test_no_krt_section(self): html = "

Другой раздел

Текст

" assert parse_krt_page(html) == [] # ── Синтетический блок: площадка без застройщика / договора / цен ───────────── # Имитирует площадки «в работе» (только geography + технические параметры). # Не требует локального дампа — всегда исполняется в CI. _BABUSHKINA_BLOCK = textwrap.dedent( """\

2024 год

 1. КРТ жилой застройки в районе ул. Бабушкина:

Площадь территории: 5,10 га;
\ Градостроительный потенциал: 120 000 кв. м;
\ Всего многоквартирных домов: 18;
\ 3. Издано Постановление Администрации города Екатеринбурга от 15.01.2024 № 42 \ «О комплексном развитии территории».

Стадия реализации: проектирование

""" ) 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), поэтому # восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ. # # Свойство, ради которого они писались, от дампа не зависит и выражается # страницей из блоков, которые уже лежат в этом файле: # • несколько

-секций с годами разбираются в одном проходе; # • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ, # поэтому «2.» встречается дважды — и site_name всё равно обязан быть # уникальным (номер сам по себе ключом не является); # • блоки лежат внутри контейнера
— это ветка `_extract_krt_section` # через `find_parent`, которой одиночные блоки выше не касаются вовсе. # # ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778): # «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора # реального документа, синтетической страницей оно не заменяется. На проде # ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но # автоматической проверки этого больше нет — она вернётся, когда в репозитории # появится образец страницы. _MULTISITE_PAGE = ( "
" + _BRUSNIKA_BLOCK + _BABUSHKINA_BLOCK + textwrap.dedent( """\

 2. КРТ нежилой застройки в районе ул. Краснофлотцев:

Площадь территории: 3,65 га;
\ Градостроительный потенциал: 117 640 кв. м;
\ Всего многоквартирных домов: 13.

""" ) + "
" ) 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