gendesign/tradein-mvp/backend/tests/scrapers/test_avito_detail_kit_parity.py
bot-backend 57963e3a06 test(tradein): reusable legacy/scraper_kit parity harness (#2304)
Stage 0 of the scraper_kit migration epic (#2277): shared test tool for
issues #2305-#2310, which each need to prove their kit-path importer
produces the same output as the legacy path on the same input.

- tests/support/parity.py: assert_parity()/compare_outputs() normalize
  dataclass/pydantic outputs to dict/list/scalar before comparing, since
  legacy vs kit dataclasses (e.g. DetailEnrichment) are different classes
  and dataclass __eq__ always returns False across classes even when all
  field values match. Supports ignore_fields (drop non-deterministic
  fields like latency_ms/fetched_at) and numeric tolerance (math.isclose)
  for float fields, with an assertion listing every differing field
  (path + legacy value + kit value) on mismatch.
- tests/support/test_parity.py: unit tests for the harness itself
  (identical outputs pass, differing outputs raise with informative
  diff, tolerance/ignore_fields options, cross-class dataclass parity).
- tests/scrapers/test_avito_detail_kit_parity.py: end-to-end smoke proof
  against real code — app.services.scrapers.avito_detail.parse_detail_html
  (legacy, reached via admin.py's scrape_avito_detail debug endpoint
  through fetch_detail) vs scraper_kit.providers.avito.detail's copy, on
  a fixed HTML fixture.
- tests/support/README.md: usage note for #2305-#2310 migration PRs.

Found while implementing: tests/test_scraper_kit_*_parity.py (9 files,
~3400 lines) already do ad-hoc `dataclasses.asdict(old) == asdict(new)`
parity checks for the already-migrated SERP scraper modules (avito/cian/
domclick/yandex/base/scheduler/pipeline) — this harness generalizes that
repeated pattern for the remaining 12 non-scraper importers, adding
ignore_fields/tolerance which those ad-hoc checks don't have.
2026-07-03 23:45:58 +03:00

102 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
# 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 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)],
)