gendesign/tradein-mvp/backend/tests/scrapers/test_avito_detail_kit_parity.py
bot-backend d95c9e6e55
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
fix(tradein/tests): harden parity harness — bool-type mismatch detection + divergence-catch proof (#2304)
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.
2026-07-03 23:45:58 +03:00

129 lines
6.9 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
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)