All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m34s
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Code-reviewer follow-up on the parity harness (aaaf8179): - parity.py: plain `==` in the scalar-equality branch let `True == 1` / `False == 0` silently pass (Python bool is an int subclass). A future migration bug turning a `bool | None` field into a raw 0/1 would slip through undetected. Now any type mismatch where exactly one side is a bool is reported as a diff, regardless of numeric equality. - test_parity.py: unit test for the new bool-vs-int branch. - test_avito_detail_kit_parity.py: the existing smoke test only proved the harness reports "no diff" on two genuinely-identical real dataclass instances — it never proved the harness catches a real divergence on this same 30+-field shape (only the toy fixtures in test_parity.py did). Added a test that mutates `price_rub` via dataclasses.replace() on the real kit DetailEnrichment and asserts assert_parity raises ParityMismatchError naming that field.
129 lines
6.9 KiB
Python
129 lines
6.9 KiB
Python
"""Smoke-proof для parity-harness'а (tests/support/parity.py) — issue #2304.
|
||
|
||
Доказывает, что harness реально ловит эквивалентность legacy ↔ scraper_kit на
|
||
РЕАЛЬНОЙ паре функций, а не на синтетике: `parse_detail_html` — parsing-ядро,
|
||
которое `app/api/v1/admin.py::scrape_avito_detail` (debug-роут ручного запуска
|
||
парсера) вызывает косвенно через `fetch_detail()` (fetch_detail = HTTP GET +
|
||
`parse_detail_html(html, url)`; сам parse — чистая функция, без сети/БД, что и
|
||
позволяет сравнить её offline на фиксированной HTML-фикстуре).
|
||
|
||
`app/services/scrapers/avito_detail.py::parse_detail_html` (legacy) и
|
||
`scraper_kit/providers/avito/detail.py::parse_detail_html` (kit) на момент
|
||
написания теста ИДЕНТИЧНЫ по телу функции (см. audit
|
||
`Scraper_Kit_Legacy_Dependency_Audit_0703` в vault) — различаются только
|
||
import-пути модулей, из которых берутся зависимости (`app.services.scrapers.*`
|
||
vs `scraper_kit.*`) и Strangler-инжекция `ScraperConfig` в соседних функциях
|
||
(`fetch_detail`, `_build_detail_session`), которую сам `parse_detail_html` не
|
||
использует. Это НЕ миграция admin.py (issue #2305+) — только доказательство
|
||
инструмента.
|
||
|
||
HTML-фикстура — тот же minimal-marker набор, что и в
|
||
`tests/test_avito_detail_parse.py::test_minimal_parse` (независимая копия
|
||
литерала, чтобы этот тест не зависел от другого test-модуля).
|
||
"""
|
||
|
||
# ruff: noqa: E501 # HTML-фикстура содержит длинные строки разметки
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
import os
|
||
|
||
import pytest
|
||
|
||
# app.services.scrapers.avito_detail импортирует app.core.config.settings=Settings(),
|
||
# которому нужен DATABASE_URL. Офлайн-парсинг БД не трогает — фиктивный DSN достаточен
|
||
# (mirror tests/test_scraper_kit_avito_golden_parity.py — тест должен жить и без
|
||
# CI-уровневого DATABASE_URL из ci-tradein.yml/deploy-tradein.yml).
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||
|
||
from scraper_kit.providers.avito.detail import (
|
||
parse_detail_html as kit_parse_detail_html,
|
||
)
|
||
|
||
from app.services.scrapers.avito_detail import (
|
||
parse_detail_html as legacy_parse_detail_html,
|
||
)
|
||
from tests.support.parity import ParityMismatchError, assert_parity
|
||
|
||
_DETAIL_HTML = """
|
||
<html><body>
|
||
<div data-marker="item-view/item-id">№ 7986882804 · 18 мая в 13:40 · 1899 просмотров (+ 12 сегодня)</div>
|
||
<span itemprop="price" content="11990000">11 990 000 ₽</span>
|
||
<div data-marker="item-map-wrapper" data-map-lat="56.797713" data-map-lon="60.609135" data-location-id="654070"></div>
|
||
<div data-marker="item-view/item-params">
|
||
<ul>
|
||
<li>Количество комнат: 3</li>
|
||
<li>Общая площадь: 75 м²</li>
|
||
<li>Площадь кухни: 15 м²</li>
|
||
<li>Этаж: 20 из 26</li>
|
||
<li>Балкон или лоджия: лоджия</li>
|
||
<li>Ремонт: евро</li>
|
||
<li>Способ продажи: свободная</li>
|
||
<li>Условия продажи: возможна ипотека</li>
|
||
</ul>
|
||
<ul>
|
||
<li>Тип дома: монолитный</li>
|
||
<li>Этажей в доме: 26</li>
|
||
</ul>
|
||
</div>
|
||
<a data-marker="nd-jk-details-button" href="/catalog/houses/ekaterinburg/ul_postovskogo_17a/3171365">Дом</a>
|
||
<div data-marker="domoteka-entry-block">
|
||
<p data-marker="TeaserData.item">1 собственник</p>
|
||
<p data-marker="TeaserData.item">Последняя смена собственника 5 мая 2020</p>
|
||
<p data-marker="TeaserData.item">Не найдены ограничения и обременения</p>
|
||
<p data-marker="TeaserData.item">Совпадают площадь, адрес и этаж</p>
|
||
</div>
|
||
<div data-marker="item-view/item-description">Отличная квартира в новом ЖК. Чкаловская 11-15 мин пешком.</div>
|
||
</body></html>
|
||
"""
|
||
_SOURCE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/test_7986882804"
|
||
|
||
|
||
def test_legacy_and_kit_parse_detail_html_are_class_distinct() -> None:
|
||
"""Sanity-check ДО harness'а: подтверждает, зачем harness вообще нужен.
|
||
|
||
legacy DetailEnrichment и kit DetailEnrichment — РАЗНЫЕ классы (разные
|
||
модули) → обычный `==` даёт False, хотя данные идентичны. Именно это
|
||
harness должен обходить через структурное сравнение (dataclasses.fields).
|
||
"""
|
||
legacy_result = legacy_parse_detail_html(_DETAIL_HTML, _SOURCE_URL)
|
||
kit_result = kit_parse_detail_html(_DETAIL_HTML, _SOURCE_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_avito_detail_parse_equivalence() -> None:
|
||
"""Реальное proof-of-concept использование harness'а (issues #2305-#2310 template)."""
|
||
assert_parity(
|
||
legacy_fn=legacy_parse_detail_html,
|
||
kit_fn=kit_parse_detail_html,
|
||
fixtures=[(_DETAIL_HTML, _SOURCE_URL)],
|
||
)
|
||
|
||
|
||
def test_parity_harness_catches_real_field_divergence() -> None:
|
||
"""Доказывает, что harness реально ЛОВИТ расхождение на этой же реальной
|
||
30+-полевой dataclass-форме — а не только сообщает "нет разницы" на двух
|
||
genuinely-identical инстансах, как предыдущий тест.
|
||
|
||
Мутируем один field (``price_rub``) в kit-результате через
|
||
``dataclasses.replace`` и убеждаемся, что harness падает с
|
||
``ParityMismatchError``, называющим именно этот field.
|
||
"""
|
||
legacy_result = legacy_parse_detail_html(_DETAIL_HTML, _SOURCE_URL)
|
||
kit_result = kit_parse_detail_html(_DETAIL_HTML, _SOURCE_URL)
|
||
assert kit_result.price_rub is not None # sanity: поле реально распарсилось из фикстуры
|
||
|
||
mutated_kit_result = dataclasses.replace(kit_result, price_rub=kit_result.price_rub + 1)
|
||
|
||
with pytest.raises(ParityMismatchError) as exc_info:
|
||
assert_parity(
|
||
legacy_fn=lambda: legacy_result,
|
||
kit_fn=lambda: mutated_kit_result,
|
||
fixtures=[()],
|
||
)
|
||
|
||
assert "price_rub" in str(exc_info.value)
|