gendesign/backend/tests/services/scrapers/test_ekb_krt_registry.py
bot-backend c087bd74b1
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Successful in 6m24s
CI / backend-tests (push) Successful in 6m22s
fix(sf): verify=False для екатеринбург.рф (CA Минцифры) + синтетический CI-тест КРТ-парсера (ревью #1126)
2026-06-07 14:22:26 +03:00

228 lines
10 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.

"""Тесты парсера реестра КРТ-площадок ЕКБ (#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>&nbsp;<strong>2. КРТ жилой застройки в квартале улицы Донбасской &ndash; \
переулков Черниговского &ndash; Суворовского:</strong></p>
<p><strong><img class="js-has-preview img-float-left" \
title="2.&Tab;КРТ жилой застройки" src="/media/gallery/fe.jpg" width="100%" /></strong></p>
<p>Площадь территории: 2,79 га;<br />Градостроительный потенциал: 76 650 кв. м;<br />\
Всего многоквартирных домов: 10;<br />\
3. Издано Постановление Администрации города Екатеринбурга от 10.03.2022 № 621 \
&laquo;О комплексном развитии территории жилой застройки в квартале улицы Донбасской&raquo;;\
<br />\
4. Постановление Администрации города Екатеринбурга от 19.05.2022 № 1334 \
&laquo;О проведении аукциона&raquo;;<br />\
5. Аукцион:<br />Начальная стоимость: 641 018,64 рублей;<br />\
Цена по результатам торгов: 671 018 рублей.<br />\
6. Заключен договор о комплексном развитии территории жилой застройки от 10.08.2022 \
№ 1-2/21.7-14 (застройщик &ndash; ООО &laquo;Брусника. Специализированный застройщик&raquo;).\
<br />\
7. Постановление Администрации города Екатеринбурга от 28.12.2022 № 4051 \
&laquo;Об утверждении проекта планировки территории&raquo;.</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>&nbsp;<strong>1. КРТ жилой застройки в районе ул. Бабушкина:</strong></p>
<p>Площадь территории: 5,10 га;<br />\
Градостроительный потенциал: 120 000 кв. м;<br />\
Всего многоквартирных домов: 18;<br />\
3. Издано Постановление Администрации города Екатеринбурга от 15.01.2024 № 42 \
&laquo;О комплексном развитии территории&raquo;.</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 == "жилой застройки"
class TestMultiSiteCountFromDump:
"""Проверка количества площадок из реального дампа страницы."""
@pytest.fixture(scope="class")
def dump_html(self):
"""Загружает локальный дамп страницы.
Тест пропускается если файл отсутствует (CI без дампа).
"""
import os
dump_path = os.path.join(
os.path.dirname(__file__),
"../../../../.playwright-mcp/ekb-docs/krt_page_raw.html",
)
if not os.path.exists(dump_path):
pytest.skip("krt_page_raw.html dump not found — skip real-dump test")
with open(dump_path, encoding="utf-8") as fh:
return fh.read()
def test_site_count_from_dump(self, dump_html):
"""Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15)."""
sites = parse_krt_page(dump_html)
assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}"
def test_no_duplicate_site_names(self, dump_html):
sites = parse_krt_page(dump_html)
names = [s.site_name for s in sites]
assert len(names) == len(set(names)), "Дубликаты site_name в результате"