"""Резюм exhaustive-обхода Cian не пробивает уже пройденную территорию (#3359). Гейт `should_skip` живёт в общем движке (`pricing.walk_price_range`, #3315), но предикат из done-леджера строил и передавал только avito: у cian та же бисекция и тот же чекпоинт, поэтому на резюме дерево деления спускалось ВНУТРЬ зачтённых полос живыми probe-запросами. Ключи чекпоинта — границы ДИНАМИЧЕСКОЙ бисекции: рынок сдвинулся → totals другие → новый лист (`4062500:4124999`) ключом не равен старому (`4000000:4999999`) даже внутри собранной территории, так что сравнение строк на резюме бесполезно — нужны интервалы. Формат ключей cian — `room_label:lo:hi` / `room_label:lo:open`, совпадает с avito (`_paginate_leaf_bucket`), парсер по умолчанию подходит без адаптации. Сеть не нужна: горлышко фетча `_fetch_page_html` подменяется счётчиком. Проверка ПО ЗНАЧЕНИЮ — сколько раз обход сходил в сеть; на origin/main тесты покрытия красные (обход делает N > 0 запросов по территории, которая уже в чекпоинте). Парсинг HTML тут не при чём (он не под тестом) — счётчик стоит на ЕДИНСТВЕННОМ сетевом вызове, а разбор страницы заглушен. """ from __future__ import annotations import asyncio import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from scraper_kit.base import ScrapedLot from scraper_kit.providers.cian.serp import CianScraper from app.services.scraper_adapters import RealScraperConfig _ROOMS = (1,) _LABEL = "room1" # Плотная выдача: total=2000 > cap=1400 → непокрытый диапазон обязан делиться дальше. _DENSE_TOTAL = 2000 def _scraper() -> tuple[CianScraper, list[int]]: """Скрапер с заглушенной сетью; в списке — счётчик реальных фетчей.""" s = CianScraper(RealScraperConfig()) calls = [0] async def fake_fetch( rooms: tuple[int, ...] | None, page: int, min_price: int | None, max_price: int | None, ) -> str: calls[0] += 1 return "dense" async def no_sleep() -> None: return None s._fetch_page_html = fake_fetch # type: ignore[method-assign] s.sleep_between_requests = no_sleep # type: ignore[method-assign] s.request_delay_sec = 0.0 s._extract_total_offers = lambda html: _DENSE_TOTAL # type: ignore[method-assign] s._parse_serp_html = lambda html: [] # type: ignore[method-assign,return-value] return s, calls def _walk(lo: int, hi: int | None, skip_buckets: set[str] | None) -> int: """Прогнать бисекцию полосы [lo, hi] и вернуть ЧИСЛО сетевых запросов.""" s, calls = _scraper() seen: dict[str, ScrapedLot] = {} asyncio.run( s._walk_price_range( rooms=_ROOMS, lo=lo, hi=hi, seen=seen, price_cap_per_bucket=1400, max_pages_per_bucket=1, skip_buckets=skip_buckets, ) ) return calls[0] def test_subrange_of_done_bucket_costs_zero_requests() -> None: """Головной: полоса ВНУТРИ done-корзины не делает ни одного запроса.""" calls = _walk(4_062_500, 4_124_999, {f"{_LABEL}:4000000:4999999"}) assert calls == 0, ( f"полоса [4062500, 4124999] целиком внутри зачтённой [4000000, 4999999], " f"а обход сходил в сеть {calls} раз(а) — бан-бюджет горит на готовой территории" ) def test_partially_covered_range_still_probes() -> None: """Контроль честности skip'а: непокрытый остаток обязан пробиваться.""" calls = _walk(3_500_000, 4_200_000, {f"{_LABEL}:4000000:4999999"}) assert calls > 0, "частично покрытая полоса пропущена целиком — потеря инвентаря" def test_empty_ledger_keeps_previous_behaviour() -> None: """Регресс-контроль: без леджера обход прежний — пробивает и делит.""" baseline = _walk(4_000_000, 4_999_999, None) assert baseline > 1, f"обход без леджера деградировал: {baseline} запрос(ов)" assert _walk(4_000_000, 4_999_999, set()) == baseline # Чужая комнатность в леджере не покрывает нашу. assert _walk(4_000_000, 4_999_999, {"room2:0:open"}) == baseline def test_fully_done_room_resumes_with_zero_requests() -> None: """Приёмка issue: резюм по полностью готовой комнатности = НОЛЬ запросов. Открытый верхний брекет в леджере записан как `label:lo:open` — это [lo, ∞). """ s, calls = _scraper() asyncio.run( s.fetch_all_secondary( rooms_buckets=[_ROOMS], max_pages_per_bucket=1, skip_buckets={f"{_LABEL}:0:open"}, ) ) assert calls[0] == 0, f"резюм готовой комнатности сделал {calls[0]} запрос(ов) вместо нуля"