"""Unit tests for N1 floor/total_floors extraction (#795). Tests _extract_floor_from_title() directly (pure function, no network/DB). Also covers _card_to_lot integration via a minimal fake card HTML node. """ from __future__ import annotations import pytest from app.services.scrapers.n1 import _extract_floor_from_title # ── _extract_floor_from_title: direct unit tests ──────────────────────────── @pytest.mark.parametrize( "text, expected_floor, expected_total", [ # Typical N1 card: floor 1 of 11 ("1/11 2-к, Репина, 75/2 стр.", 1, 11), # Floor in middle of building ("5/9 3-к, Ленина, 10 стр.", 5, 9), # Top floor ("17/17 1-к, Малышева, 12", 17, 17), # Leading whitespace tolerated (" 3/16 студия, Вайнера, 5", 3, 16), # Three-digit total floors (edge of range) ("2/100 2-к, Тверитина, 8", 2, 100), ], ) def test_extract_floor_happy_path(text: str, expected_floor: int, expected_total: int) -> None: floor, total = _extract_floor_from_title(text) assert floor == expected_floor assert total == expected_total @pytest.mark.parametrize( "text", [ # No leading N/M at all — classic old format without floor token ("2-к, Репина, 75/2 стр."), # House number fraction in middle — must NOT be parsed as floor ("Репина, 75/2 стр."), # N/M not at string start (preceded by other text) ("квартира 3/9, Ленина, 1"), # floor=0 is invalid ("0/9 1-к, Тестовая, 1"), # total_floors < floor — sanity fail ("9/5 2-к, Тестовая, 1"), # total_floors > 100 — sanity fail ("2/101 1-к, Тестовая, 1"), # Empty string (""), # Only digits, no slash ("5 2-к, Тестовая, 1"), ], ) def test_extract_floor_returns_none(text: str) -> None: floor, total = _extract_floor_from_title(text) assert floor is None assert total is None def test_house_number_fraction_not_misparsed() -> None: """«75/2 стр.» appearing after rooms segment must not match floor regex.""" # The fraction 75/2 comes AFTER the rooms prefix, not at string start. floor, total = _extract_floor_from_title("3-к, Репина, 75/2 стр.") assert floor is None assert total is None def test_non_leading_fraction_after_comma() -> None: """Fraction embedded mid-string via comma-separated address token.""" floor, total = _extract_floor_from_title("ул. 8 Марта, 51/3, 2-к") assert floor is None assert total is None # ── Integration: _card_to_lot via fake selectolax node ────────────────────── class _FakeAttr(dict): """Minimal attributes dict for a fake HTML node.""" class _FakeNode: """Minimal selectolax node stub for _card_to_lot tests.""" def __init__( self, *, href: str = "/view/12345/", title_text: str = "1/11 2-к, Репина, 75/2 стр.", area_text: str = "52 м2", price_text: str = "8 500 000", img_src: str = "https://img.n1.ru/photo.jpg", ) -> None: self._href = href self._title_text = title_text self._area_text = area_text self._price_text = price_text self._img_src = img_src def css_first(self, selector: str) -> _FakeNode | None: if 'a[href*="/view/"]' in selector: return self if "total-area" in selector: return _FakeTextNode(self._area_text) if "param-price" in selector: return _FakeTextNode(self._price_text) return None def css(self, selector: str) -> list[_FakeNode]: if selector == "img": return [_FakeImgNode(self._img_src)] return [] @property def attributes(self) -> dict: return {"href": self._href} def text(self, strip: bool = False) -> str: t = self._title_text return t.strip() if strip else t class _FakeTextNode: def __init__(self, text: str) -> None: self._text = text def text(self, strip: bool = False) -> str: return self._text.strip() if strip else self._text class _FakeImgNode: def __init__(self, src: str) -> None: self.attributes = {"src": src} def _call_card_to_lot(card: _FakeNode): # type: ignore[return] from app.services.scrapers.n1 import N1Scraper scraper = object.__new__(N1Scraper) scraper.base_url = "https://ekaterinburg.n1.ru" # type: ignore[attr-defined] return scraper._card_to_lot(card) # type: ignore[attr-defined] def test_card_to_lot_parses_floor() -> None: card = _FakeNode(title_text="1/11 2-к, Репина, 75/2 стр.") lot = _call_card_to_lot(card) assert lot is not None assert lot.floor == 1 assert lot.total_floors == 11 def test_card_to_lot_floor_none_when_no_token() -> None: card = _FakeNode(title_text="2-к, Репина, 75/2 стр.") lot = _call_card_to_lot(card) assert lot is not None assert lot.floor is None assert lot.total_floors is None def test_card_to_lot_listing_date_is_none() -> None: """N1 SERP has no date — listing_date must remain None.""" card = _FakeNode(title_text="3/9 1-к, Малышева, 12") lot = _call_card_to_lot(card) assert lot is not None assert lot.listing_date is None