All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 25s
Deploy Trade-In / build-backend (push) Successful in 41s
Deploy Trade-In / deploy (push) Successful in 34s
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
169 lines
5.5 KiB
Python
169 lines
5.5 KiB
Python
"""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
|