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

187 lines
8.7 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 isinstance(legacy_val, bool) != isinstance(kit_val, bool):
# bool — подкласс int в Python, поэтому `True == 1` и `False == 0` дают True
# при обычном `==` (проверять надо isinstance(x, bool), НЕ isinstance(x, int)).
# Миграция, случайно превратившая `bool | None` поле в сырой 0/1 (или наоборот),
# должна считаться расхождением, даже если числовое значение совпадает.
diffs.append(
f"{path}: type mismatch (bool vs non-bool) — legacy={legacy_val!r}, kit={kit_val!r}"
)
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}"
)