"""Unit tests for yandex_newbuilding.py — ЖК landing parser (Worker B, Wave 4). Reference target: ЖК Татлин (slug=tatlin, id=1592987) — comfort+, June 2023, PRINZIP developer, rating 4.3, 1505 ratings, 353 text reviews, coords (56.855312, 60.576668). All tests are offline — no network calls. Parser receives hand-crafted fixture HTML. """ from __future__ import annotations import pytest from app.services.scrapers.yandex_newbuilding import ( JKMetroStation, YandexNewbuildingInfo, YandexNewbuildingScraper, _extract_coords, _find_section_text, _parse_corpus_count, _parse_metro, ) # ── Fixture HTML ────────────────────────────────────────────────────────────── TATLIN_FIXTURE_HTML = """ ЖК «Татлин»

ЖК «Татлин»

Екатеринбург, ул. Черепанова / ул. Готвальда
PRINZIP
ЖК Кислород ЖК Нова
56.855312, 60.576668
Уральская 11 мин. Динамо 16 мин.
4.3 из 5 1505 оценок Смотреть все 353 отзыва

О комплексе

ЖК «Татлин» — три 35-этажные башни в Заречном микрорайоне Екатеринбурга. Комплекс класса комфорт+. Введён в эксплуатацию в июне 2023 года. Расположен на участке 1,5 га. Монолитный тип дома.

Расположение

В 5 минутах от центра

""" # Minimal fixture — missing optional sections MINIMAL_FIXTURE_HTML = """

ЖК Минимальный

""" # ── Full happy-path test ────────────────────────────────────────────────────── def test_parse_jk_full_fixture(): """Happy path: all major fields extracted from Татлин-like HTML.""" scraper = YandexNewbuildingScraper() info = scraper.parse( TATLIN_FIXTURE_HTML, jk_slug="tatlin", jk_id="1592987", source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1592987/", ) assert isinstance(info, YandexNewbuildingInfo) assert info.ext_id == "1592987" assert info.ext_slug == "tatlin" assert "Татлин" in (info.name or "") # coords assert info.lat == pytest.approx(56.855312, abs=1e-5) assert info.lon == pytest.approx(60.576668, abs=1e-5) # class + commission assert info.house_class == "comfort_plus" assert info.commission_year == 2023 assert info.commission_month == "июне" # footprint assert info.total_floors == 35 assert info.corpus_count == 3 assert info.total_area_ha == pytest.approx(1.5, abs=0.01) # developer assert info.developer_name == "PRINZIP" assert info.developer_url is not None and "prinzip" in info.developer_url assert "ЖК Кислород" in info.developer_other_jk assert "ЖК Нова" in info.developer_other_jk # reviews assert info.rating == pytest.approx(4.3, abs=0.01) assert info.ratings_count == 1505 assert info.text_reviews_count == 353 # description present assert info.description is not None assert len(info.description) > 10 # metro assert len(info.metro_stations) >= 1 station_names = [s.name for s in info.metro_stations] assert any("Уральская" in n for n in station_names) # house type from "монолитный" assert info.house_type == "monolith" # raw_payload assert info.raw_payload is not None assert "body_len" in info.raw_payload # ── Coord extraction tests ──────────────────────────────────────────────────── def test_extract_coords_within_ekb_range(): html = "
56.855312, 60.576668
" lat, lon = _extract_coords(html) assert lat == pytest.approx(56.855312, abs=1e-5) assert lon == pytest.approx(60.576668, abs=1e-5) def test_extract_coords_outside_range_returns_none(): # Moscow coords — outside EKB range html = "
55.7558, 37.6176
" _lat, lon = _extract_coords(html) # 55.75 is in LAT_RANGE but 37.61 is NOT in LON_RANGE assert lon is None def test_extract_coords_no_coords_returns_none(): html = "
Текст без координат
" lat, lon = _extract_coords(html) assert lat is None assert lon is None def test_extract_coords_multiple_takes_first(): """When multiple valid coords present, first is returned.""" html = "
56.855312, 60.576668
56.900000, 60.600000
" lat, lon = _extract_coords(html) assert lat == pytest.approx(56.855312, abs=1e-5) assert lon == pytest.approx(60.576668, abs=1e-5) # ── Corpus count tests ──────────────────────────────────────────────────────── def test_parse_corpus_count_word_three(): text = "три 35-этажные башни в центре" result = _parse_corpus_count(text) assert result == 3 def test_parse_corpus_count_digit(): text = "5 35-этажных корпусов" result = _parse_corpus_count(text) assert result == 5 def test_parse_corpus_count_two(): text = "две 20-этажные башни" result = _parse_corpus_count(text) assert result == 2 def test_parse_corpus_count_none_when_no_match(): text = "Обычный текст без этажей" result = _parse_corpus_count(text) assert result is None # ── House class test ────────────────────────────────────────────────────────── def test_parse_house_class_comfort_plus_from_description(): from app.services.scrapers.yandex_helpers import parse_house_class text = "Жилой комплекс класса комфорт+ в центре города" result = parse_house_class(text) assert result == "comfort_plus" def test_parse_house_class_business(): from app.services.scrapers.yandex_helpers import parse_house_class text = "ЖК класса бизнес" result = parse_house_class(text) assert result == "business" # ── Commission year/month test ──────────────────────────────────────────────── def test_parse_commission_year_month(): """Full pipeline: parse() correctly extracts commission_year and commission_month.""" scraper = YandexNewbuildingScraper() info = scraper.parse( TATLIN_FIXTURE_HTML, jk_slug="tatlin", jk_id="1592987", source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1592987/", ) assert info.commission_year == 2023 assert info.commission_month == "июне" def test_parse_commission_sdan_variant(): """Regex supports 'сдан в эксплуатацию' phrasing as well.""" html = """

ЖК Тест

О комплексе

Сдан в эксплуатацию в марте 2025 года.

""" scraper = YandexNewbuildingScraper() info = scraper.parse(html, jk_slug="test", jk_id="999", source_url="http://x") assert info.commission_year == 2025 assert info.commission_month == "марте" # ── Rating + counts test ────────────────────────────────────────────────────── def test_parse_rating_and_counts(): scraper = YandexNewbuildingScraper() info = scraper.parse( TATLIN_FIXTURE_HTML, jk_slug="tatlin", jk_id="1592987", source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1592987/", ) assert info.rating == pytest.approx(4.3, abs=0.01) assert info.ratings_count == 1505 assert info.text_reviews_count == 353 def test_parse_rating_comma_separator(): """Rating '4,3 из 5' with comma handled correctly.""" html = "
4,3 из 5 200 оценок
" scraper = YandexNewbuildingScraper() info = scraper.parse(html, jk_slug="x", jk_id="1", source_url="http://x") assert info.rating == pytest.approx(4.3, abs=0.01) assert info.ratings_count == 200 # ── Developer link test ─────────────────────────────────────────────────────── def test_developer_name_extracted_from_data_test_link(): scraper = YandexNewbuildingScraper() info = scraper.parse( TATLIN_FIXTURE_HTML, jk_slug="tatlin", jk_id="1592987", source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1592987/", ) assert info.developer_name == "PRINZIP" assert info.developer_url == "https://realty.yandex.ru/developer/prinzip" def test_developer_absolute_url_kept_as_is(): html = """ АБС Групп """ scraper = YandexNewbuildingScraper() info = scraper.parse(html, jk_slug="x", jk_id="1", source_url="http://x") assert info.developer_name == "АБС Групп" assert info.developer_url == "https://realty.yandex.ru/developer/abc" # ── Metro stations test ─────────────────────────────────────────────────────── def test_metro_stations_parsed(): scraper = YandexNewbuildingScraper() info = scraper.parse( TATLIN_FIXTURE_HTML, jk_slug="tatlin", jk_id="1592987", source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1592987/", ) assert len(info.metro_stations) >= 1 uralskaya = next( (s for s in info.metro_stations if "Уральская" in s.name), None ) assert uralskaya is not None assert uralskaya.walk_min == 11 def test_parse_metro_direct(): text = "Уральская 11 мин. Динамо 16 мин." stations = _parse_metro(text) assert len(stations) == 2 assert stations[0].name == "Уральская" assert stations[0].walk_min == 11 assert stations[1].name == "Динамо" assert stations[1].walk_min == 16 def test_parse_metro_caps_at_five(): text = ( "Альфа 5 мин. Бета 6 мин. Гамма 7 мин. " "Дельта 8 мин. Эпсилон 9 мин. Зета 10 мин." ) stations = _parse_metro(text) assert len(stations) == 5 # ── No description section test ─────────────────────────────────────────────── def test_no_description_section_returns_none(): """When no О комплексе / Расположение section, description is None.""" scraper = YandexNewbuildingScraper() info = scraper.parse( MINIMAL_FIXTURE_HTML, jk_slug="minimal", jk_id="42", source_url="http://example.com/", ) assert info.description is None assert info.house_class is None assert info.commission_year is None assert info.total_floors is None # ── fetch_around raises NotImplementedError ─────────────────────────────────── @pytest.mark.asyncio async def test_fetch_around_raises_not_implemented(): scraper = YandexNewbuildingScraper() with pytest.raises(NotImplementedError, match="JK-slug-based"): await scraper.fetch_around(56.855, 60.576) # ── _find_section_text helper ───────────────────────────────────────────────── def test_find_section_text_returns_content_after_heading(): from selectolax.parser import HTMLParser html = """

О комплексе

Описание комплекса здесь.

Ещё абзац.

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

Не должен попасть.

""" tree = HTMLParser(html) result = _find_section_text(tree, "О комплексе") assert result is not None assert "Описание комплекса" in result assert "Не должен" not in result def test_find_section_text_returns_none_when_missing(): from selectolax.parser import HTMLParser html = "

Текст

" tree = HTMLParser(html) result = _find_section_text(tree, "О комплексе") assert result is None # ── Developer other JK dedup ────────────────────────────────────────────────── def test_developer_other_jk_dedup(): """Duplicate JK names are not added twice.""" html = """
ЖК Один ЖК Один ЖК Два
""" scraper = YandexNewbuildingScraper() info = scraper.parse(html, jk_slug="x", jk_id="1", source_url="http://x") assert info.developer_other_jk.count("ЖК Один") == 1 assert "ЖК Два" in info.developer_other_jk def test_developer_other_jk_capped_at_10(): """Only first 10 other JK entries are kept.""" links = "\n".join( f'ЖК {i}' for i in range(15) ) html = f"""
{links}
""" scraper = YandexNewbuildingScraper() info = scraper.parse(html, jk_slug="x", jk_id="1", source_url="http://x") assert len(info.developer_other_jk) == 10 # ── model defaults ──────────────────────────────────────────────────────────── def test_yandex_newbuilding_info_defaults(): """Model initialises with safe defaults for all optional fields.""" info = YandexNewbuildingInfo(ext_id="1", ext_slug="x", source_url="http://x") assert info.name is None assert info.lat is None assert info.lon is None assert info.house_class is None assert info.developer_other_jk == [] assert info.metro_stations == [] assert info.raw_payload is None def test_jk_metro_station_model(): station = JKMetroStation(name="Уральская", walk_min=11) assert station.name == "Уральская" assert station.walk_min == 11 station_no_time = JKMetroStation(name="Геологическая") assert station_no_time.walk_min is None