"""Parity-proof: `app.services.scrapers.domclick_detail` vs `scraper_kit.providers.domclick.detail` (issue #2307, Group D). `parse_detail_html` — parsing-ядро без сети/БД (SSR-стейт → dataclass), поэтому сравнивается offline на фиксированной HTML-фикстуре через `tests.support.parity.assert_parity` (harness #2304). Legacy и kit тела функции на момент написания теста ИДЕНТИЧНЫ (см. audit `Scraper_Kit_Legacy_Dependency_Audit_0703` в vault) — различаются только import-пути зависимостей (`app.services.scrapers.*` vs `scraper_kit.*`). HTML-фикстура — независимая копия литерала из `tests/scrapers/test_domclick_detail.py::_HTML` (тот же live-card ground truth 2075729321, 2026-06-27), чтобы этот тест не зависел от другого test-модуля. """ # SSR-JSON фикстура содержит длинные строки from __future__ import annotations import dataclasses import os import pytest # app.services.scrapers.domclick_detail не читает settings напрямую, но соседние # модули пакета app.services.scrapers делают это при импорте app.core.config — # фиктивный DSN мостит офлайн-запуск без CI-уровневого DATABASE_URL (mirror # tests/scrapers/test_avito_detail_kit_parity.py). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from scraper_kit.domclick_exceptions import ( DomClickBlockedError as KitDomClickBlockedError, ) from scraper_kit.domclick_exceptions import ( DomClickParseError as KitDomClickParseError, ) from scraper_kit.providers.domclick.detail import ( parse_detail_html as kit_parse_detail_html, ) from app.services.scrapers.domclick_detail import ( parse_detail_html as legacy_parse_detail_html, ) from app.services.scrapers.domclick_exceptions import ( DomClickBlockedError as LegacyDomClickBlockedError, ) from app.services.scrapers.domclick_exceptions import ( DomClickParseError as LegacyDomClickParseError, ) from tests.support.parity import ParityMismatchError, assert_parity _CARD_URL = "https://ekaterinburg.domclick.ru/card/sale__flat__2075729321" # Real __SSR_STATE__ shape (ground truth, live card 2075729321, 2026-06-27). # bare `undefined` (value-position), in-string "undefined", '{' внутри строки и # эскейп-кавычки — написан вручную (json.dumps использовать нельзя, нужен # именно НЕвалидный-для-json undefined). _SSR_LITERAL = """{ "productCard": { "objectInfo": { "area": 38.0, "rooms": 1, "floor": 5, "isApartment": false, "renovation": "евроремонт", "livingArea": 22.37, "kitchenArea": 10.13, "description": "Состояние пока undefined.", "note": "угол {da}", "quote": "сказал \\"привет\\" сосед" }, "legalOptions": {"saleType": "Свободная продажа"}, "egrnData": { "area": 38.2, "floor": 5, "owners_count": 1, "collateral": true, "collateral_sber": false }, "priceInfo": { "priceHistory": [ {"date": "2026-04-15T09:02:26.548728+03:00", "price": 13400000, "diff": -400000, "state": "less"}, {"date": "not-a-date", "price": 4800000, "diff": -1.0, "state": "less"}, {"date": "2026-05-01T10:00:00Z", "price": null, "diff": 0, "state": "more"} ] }, "viewsCount": 338, "callsCount": 5, "favoriteOfferUsersCount": 12, "duplicatesOfferCount": 0, "address": {"guid": "abc-guid-123"}, "unknownField": undefined }, "houseInfo": { "info": { "buildYear": 2015, "wallType": "Кирпично-монолитный", "floorType": "Железобетонный", "entranceCount": 3, "quartersCount": 120, "energyEfficiency": "B", "buildingSeries": "индивидуальный" } }, "pricePrediction": { "market_price": 13000000, "min_market_price": 12000000, "max_market_price": 14000000, "rent_short": 50000, "rent_long": 35000, "repair_quality": "good" } }""" _HTML = ( "" ) def test_legacy_and_kit_parse_detail_html_are_class_distinct() -> None: """Sanity-check ДО harness'а: legacy/kit DomClickDetailEnrichment — РАЗНЫЕ классы (разные модули) → обычный `==` даёт False, хотя данные идентичны. Именно это harness обходит через структурное сравнение (dataclasses.fields). """ legacy_result = legacy_parse_detail_html(_HTML, _CARD_URL) kit_result = kit_parse_detail_html(_HTML, _CARD_URL) assert type(legacy_result) is not type(kit_result) assert legacy_result != kit_result # dataclass __eq__ проверяет class identity первым assert dataclasses.asdict(legacy_result) == dataclasses.asdict(kit_result) def test_parity_harness_confirms_domclick_detail_parse_equivalence() -> None: """Реальное использование harness'а для DomClick Layer B (issue #2307).""" assert_parity( legacy_fn=legacy_parse_detail_html, kit_fn=kit_parse_detail_html, fixtures=[(_HTML, _CARD_URL)], ) def test_parity_harness_catches_real_field_divergence() -> None: """Harness реально ЛОВИТ расхождение на реальной форме — мутируем один field (``living_area_m2``) в kit-результате и убеждаемся, что harness падает с ``ParityMismatchError``, называющим именно этот field. """ legacy_result = legacy_parse_detail_html(_HTML, _CARD_URL) kit_result = kit_parse_detail_html(_HTML, _CARD_URL) assert kit_result.living_area_m2 is not None # sanity: поле реально распарсилось mutated_kit_result = dataclasses.replace( kit_result, living_area_m2=kit_result.living_area_m2 + 1 ) with pytest.raises(ParityMismatchError) as exc_info: assert_parity( legacy_fn=lambda: legacy_result, kit_fn=lambda: mutated_kit_result, fixtures=[()], ) assert "living_area_m2" in str(exc_info.value) def test_legacy_and_kit_exceptions_behave_identically_on_challenge_page() -> None: """Anti-bot challenge page (no __SSR_STATE__, block-маркеры) → оба пути поднимают семантически эквивалентный DomClickBlockedError (разные классы, те же условия детекции — зеркало `test_scraper_kit_domclick_golden_parity.py` для Layer A exceptions). """ challenge_html = "Access denied by DataDome captcha" with pytest.raises(LegacyDomClickBlockedError): legacy_parse_detail_html(challenge_html, _CARD_URL) with pytest.raises(KitDomClickBlockedError): kit_parse_detail_html(challenge_html, _CARD_URL) def test_legacy_and_kit_exceptions_behave_identically_on_missing_state() -> None: """HTML без __SSR_STATE__ и без block-маркеров → оба пути поднимают DomClickParseError (дрейф схемы / повреждённый ответ, не блок).""" no_state_html = "no ssr state here" with pytest.raises(LegacyDomClickParseError): legacy_parse_detail_html(no_state_html, _CARD_URL) with pytest.raises(KitDomClickParseError): kit_parse_detail_html(no_state_html, _CARD_URL)