gendesign/tradein-mvp/backend/tests/support/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

178 lines
8 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.

"""Общий harness для parity-тестов legacy → scraper_kit (issue #2304).
Каждая миграция importer'а legacy `app/services/scrapers/*` на его
`scraper_kit` эквивалент (issues #2305-#2310) должна доказать, что kit-путь
даёт ТОТ ЖЕ результат, что и legacy-путь на одном и том же входе. Это —
переиспользуемый инструмент для такого доказательства, НЕ сама миграция.
Usage (см. tests/support/README.md для подробностей)::
from tests.support.parity import assert_parity
def test_parse_detail_html_parity() -> None:
assert_parity(
legacy_fn=legacy_parse_detail_html,
kit_fn=kit_parse_detail_html,
fixtures=[(html, source_url)],
ignore_fields={"latency_ms", "fetched_at"},
tolerance=1e-6,
)
Design note: legacy- и kit-версии одного и того же dataclass (напр.
``DetailEnrichment``) — это РАЗНЫЕ классы (разные модули), поэтому обычный
``==`` между их инстансами всегда False (dataclass ``__eq__`` сравнивает
``other.__class__ is self.__class__`` первым делом). Поэтому сравнение
нормализует оба вывода в dict/list/scalar (``_normalize``) и сравнивает
СТРУКТУРНО, по полям — а не через identity классов.
"""
from __future__ import annotations
import dataclasses
import math
from collections.abc import Callable, Iterable, Mapping, Sequence
from typing import Any
class ParityMismatchError(AssertionError):
"""legacy_fn(...) и kit_fn(...) дали разный результат на одной фикстуре.
Подкласс AssertionError — совместим с `pytest.raises(AssertionError)`
и обычным assert-репортингом pytest.
"""
def _normalize(value: Any) -> Any:
"""Приводит dataclass / pydantic-модель к сравнимому dict/list/scalar.
Рекурсивно — вложенные dataclass/pydantic-поля тоже нормализуются.
"""
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return {f.name: _normalize(getattr(value, f.name)) for f in dataclasses.fields(value)}
if hasattr(value, "model_dump") and callable(value.model_dump):
return _normalize(value.model_dump())
if isinstance(value, Mapping):
return {k: _normalize(v) for k, v in value.items()}
if isinstance(value, list | tuple):
return [_normalize(v) for v in value]
return value
def _is_number(value: Any) -> bool:
# bool — подкласс int, но семантически не "число с допуском".
return isinstance(value, int | float) and not isinstance(value, bool)
def _within_tolerance(legacy_val: Any, kit_val: Any, tolerance: float) -> bool:
if not tolerance or not _is_number(legacy_val) or not _is_number(kit_val):
return False
return math.isclose(float(legacy_val), float(kit_val), rel_tol=tolerance, abs_tol=tolerance)
def _diff(
path: str,
legacy_val: Any,
kit_val: Any,
ignore_fields: frozenset[str],
tolerance: float,
diffs: list[str],
) -> None:
legacy_val = _normalize(legacy_val)
kit_val = _normalize(kit_val)
legacy_is_map = isinstance(legacy_val, Mapping)
kit_is_map = isinstance(kit_val, Mapping)
if legacy_is_map or kit_is_map:
if not (legacy_is_map and kit_is_map):
diffs.append(f"{path}: type mismatch — legacy={legacy_val!r}, kit={kit_val!r}")
return
for key in sorted({*legacy_val.keys(), *kit_val.keys()}, key=str):
if key in ignore_fields:
continue
child_path = f"{path}.{key}"
if key not in legacy_val:
diffs.append(f"{child_path}: missing in legacy, kit={kit_val[key]!r}")
elif key not in kit_val:
diffs.append(f"{child_path}: legacy={legacy_val[key]!r}, missing in kit")
else:
_diff(child_path, legacy_val[key], kit_val[key], ignore_fields, tolerance, diffs)
return
legacy_is_seq = isinstance(legacy_val, list | tuple)
kit_is_seq = isinstance(kit_val, list | tuple)
if legacy_is_seq or kit_is_seq:
if not (legacy_is_seq and kit_is_seq):
diffs.append(f"{path}: type mismatch — legacy={legacy_val!r}, kit={kit_val!r}")
return
if len(legacy_val) != len(kit_val):
diffs.append(f"{path}: length differs — legacy={len(legacy_val)}, kit={len(kit_val)}")
return
for i, (lv, kv) in enumerate(zip(legacy_val, kit_val, strict=True)):
_diff(f"{path}[{i}]", lv, kv, ignore_fields, tolerance, diffs)
return
if legacy_val == kit_val:
return
if _within_tolerance(legacy_val, kit_val, tolerance):
return
diffs.append(f"{path}: legacy={legacy_val!r} != kit={kit_val!r}")
def compare_outputs(
legacy_output: Any,
kit_output: Any,
*,
ignore_fields: Iterable[str] = (),
tolerance: float = 0.0,
) -> list[str]:
"""Возвращает список текстовых различий между двумя выводами (пусто = совпадают).
ignore_fields — имена полей (ключи dict / поля dataclass на ЛЮБОМ уровне
вложенности), которые пропускаются при сравнении — для недетерминированных
полей вроде ``latency_ms`` / ``fetched_at`` / ``request_id``.
tolerance — абсолютно-и-относительный допуск (``math.isclose``) для
числовых полей (int/float, НЕ bool). Не применяется к datetime/date —
такие поля, если недетерминированы, кладите в ignore_fields.
"""
diffs: list[str] = []
_diff("$", legacy_output, kit_output, frozenset(ignore_fields), tolerance, diffs)
return diffs
def assert_parity(
legacy_fn: Callable[..., Any],
kit_fn: Callable[..., Any],
fixtures: Sequence[Any],
*,
ignore_fields: Iterable[str] = (),
tolerance: float = 0.0,
) -> None:
"""Прогоняет legacy_fn и kit_fn на каждой фикстуре, сверяет вывод.
Args:
legacy_fn: функция из `app/services/scrapers/*` (legacy-путь).
kit_fn: соответствующая функция из `scraper_kit` (kit-путь).
fixtures: список входов. Каждый элемент фикстуры:
- tuple/list → распаковывается как позиционные аргументы (``*args``);
- любое другое значение (dict, str, ...) → передаётся как ЕДИНСТВЕННЫЙ
позиционный аргумент.
ignore_fields: имена полей, игнорируемых при сравнении (см. compare_outputs).
tolerance: числовой допуск для float-полей (см. compare_outputs).
Raises:
ParityMismatchError: если legacy_fn(...) != kit_fn(...) хотя бы на одной
фикстуре. Сообщение перечисляет ВСЕ различающиеся поля (path + old/new).
"""
for i, fixture in enumerate(fixtures):
args = fixture if isinstance(fixture, tuple | list) else (fixture,)
legacy_result = legacy_fn(*args)
kit_result = kit_fn(*args)
diffs = compare_outputs(
legacy_result, kit_result, ignore_fields=ignore_fields, tolerance=tolerance
)
if diffs:
diff_text = "\n".join(f" - {d}" for d in diffs)
raise ParityMismatchError(
f"Parity mismatch on fixture #{i} (input={fixture!r}):\n{diff_text}"
)