"""Tests for YandexDetailScraper structural-source extraction (#1792). Area / ceiling / kitchen / living / floor are extracted from the embedded ``window.INITIAL_STATE → offerCard.card`` object rather than the h1 title. The title misses non-standard layouts (студия / свободная планировка) and never carries ceiling/kitchen — which is why DB area coverage sat at ~63%. Fixtures are trimmed real offer pages fetched via the headless browser: - yandex_offer_3402418396407468801.html — 1-комн. вторичка (full chars) - yandex_offer_7811691822126445498.html — студия / новостройка Легаси `app.services.scrapers.yandex_detail` удалён (#2277 финальный шаг scraper_kit-миграции, 0 runtime importers) — тесты переведены на kit-эквивалент `scraper_kit.providers.yandex.detail` без изменения покрытия. """ from __future__ import annotations from pathlib import Path import pytest from scraper_kit.providers.yandex.detail import ( YandexDetailScraper, _extract_offer_card, _parse_card_fields, ) FIXTURES = Path(__file__).parent / "fixtures" SCRAPER = YandexDetailScraper() # offer_id -> expected structural values _SECONDARY_ID = "3402418396407468801" _STUDIO_ID = "7811691822126445498" def _load(offer_id: str) -> str: return (FIXTURES / f"yandex_offer_{offer_id}.html").read_text(encoding="utf-8") def _url(offer_id: str) -> str: return f"https://realty.yandex.ru/offer/{offer_id}/" # --------------------------------------------------------------------------- # Secondary (1-комнатная) — все структурные поля присутствуют # --------------------------------------------------------------------------- class TestSecondaryOfferStructural: def test_area_from_structural_source(self) -> None: r = SCRAPER.parse(_load(_SECONDARY_ID), offer_url=_url(_SECONDARY_ID)) assert r is not None assert r.area_m2 == pytest.approx(35.5) def test_rooms_floor_from_structural_source(self) -> None: r = SCRAPER.parse(_load(_SECONDARY_ID), offer_url=_url(_SECONDARY_ID)) assert r is not None assert r.rooms == 1 # floor from floorsOffered[0]; total from floorsTotal assert r.floor == 4 assert r.total_floors == 9 def test_ceiling_kitchen_living_present(self) -> None: r = SCRAPER.parse(_load(_SECONDARY_ID), offer_url=_url(_SECONDARY_ID)) assert r is not None # These never come from the title — only the structural block carries them. assert r.ceiling_height == pytest.approx(2.55) assert r.kitchen_area_m2 == pytest.approx(9.4) assert r.living_area_m2 == pytest.approx(16.9) # --------------------------------------------------------------------------- # Studio / новостройка — area present even when title says «квартира-студия» # --------------------------------------------------------------------------- class TestStudioOfferStructural: def test_area_extracted_for_studio(self) -> None: r = SCRAPER.parse(_load(_STUDIO_ID), offer_url=_url(_STUDIO_ID)) assert r is not None # The key regression: studio area must NOT be None. assert r.area_m2 is not None assert r.area_m2 == pytest.approx(24.92) def test_studio_rooms_zero_from_house_flag(self) -> None: r = SCRAPER.parse(_load(_STUDIO_ID), offer_url=_url(_STUDIO_ID)) assert r is not None # roomsTotal is null for studios; house.studio == true → rooms = 0. assert r.rooms == 0 def test_studio_floor_extracted(self) -> None: r = SCRAPER.parse(_load(_STUDIO_ID), offer_url=_url(_STUDIO_ID)) assert r is not None assert r.floor == 9 assert r.total_floors == 25 def test_studio_ceiling_kitchen_legitimately_absent(self) -> None: # This is a newbuilding offer — Yandex does not expose ceiling/kitchen/ # living for it. None here is legitimate, not a parse miss. r = SCRAPER.parse(_load(_STUDIO_ID), offer_url=_url(_STUDIO_ID)) assert r is not None assert r.ceiling_height is None assert r.kitchen_area_m2 is None assert r.living_area_m2 is None # --------------------------------------------------------------------------- # Helper-level coverage # --------------------------------------------------------------------------- class TestStructuralHelpers: def test_extract_offer_card_matches_offer_id(self) -> None: card = _extract_offer_card(_load(_SECONDARY_ID), _SECONDARY_ID) assert card is not None assert card["offerId"] == _SECONDARY_ID def test_extract_offer_card_missing_state_returns_none(self) -> None: assert _extract_offer_card("no state", "123") is None def test_parse_card_fields_space_objects(self) -> None: card = { "roomsTotal": 2, "area": {"value": 55.3, "unit": "SQUARE_METER"}, "livingSpace": {"value": 30.0, "unit": "SQUARE_METER"}, "kitchenSpace": {"value": 12.5, "unit": "SQUARE_METER"}, "ceilingHeight": 2.7, "floorsTotal": 18, "floorsOffered": [7], "house": {"studio": False}, } rooms, area, living, kitchen, ceiling, floor, total = _parse_card_fields(card) assert rooms == 2 assert area == pytest.approx(55.3) assert living == pytest.approx(30.0) assert kitchen == pytest.approx(12.5) assert ceiling == pytest.approx(2.7) assert floor == 7 assert total == 18 def test_parse_card_fields_studio_flag_sets_rooms_zero(self) -> None: card = { "roomsTotal": None, "area": {"value": 24.92, "unit": "SQUARE_METER"}, "floorsOffered": [9], "floorsTotal": 25, "house": {"studio": True}, } rooms, area, living, kitchen, ceiling, floor, total = _parse_card_fields(card) assert rooms == 0 assert area == pytest.approx(24.92) assert living is None assert kitchen is None assert ceiling is None assert floor == 9 assert total == 25