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.
This commit is contained in:
parent
a8b504c3de
commit
57963e3a06
5 changed files with 479 additions and 0 deletions
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""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)],
|
||||||
|
)
|
||||||
80
tradein-mvp/backend/tests/support/README.md
Normal file
80
tradein-mvp/backend/tests/support/README.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# tests/support/ — общие test-инструменты (не сами тесты)
|
||||||
|
|
||||||
|
## parity.py — legacy → scraper_kit parity harness (issue #2304)
|
||||||
|
|
||||||
|
Инструмент для issues #2305-#2310 (миграция 12 неймигрированных importers
|
||||||
|
`app/services/scrapers/*` → `scraper_kit` эквиваленты, см. audit
|
||||||
|
`Scraper_Kit_Legacy_Dependency_Audit_0703` в vault). Каждая такая миграция
|
||||||
|
должна доказать, что kit-путь даёт ТОТ ЖЕ результат, что и legacy-путь на
|
||||||
|
одном и том же входе — для этого используйте `assert_parity`.
|
||||||
|
|
||||||
|
### Быстрый старт
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.services.scrapers.cian_valuation import evaluate_via_cian as legacy_fn
|
||||||
|
from scraper_kit.providers.cian.valuation import evaluate_via_cian as kit_fn
|
||||||
|
from tests.support.parity import assert_parity
|
||||||
|
|
||||||
|
|
||||||
|
def test_cian_valuation_parity() -> None:
|
||||||
|
assert_parity(
|
||||||
|
legacy_fn=legacy_fn,
|
||||||
|
kit_fn=kit_fn,
|
||||||
|
fixtures=[
|
||||||
|
(fixture_html_1, "https://cian.ru/flat/1"),
|
||||||
|
(fixture_html_2, "https://cian.ru/flat/2"),
|
||||||
|
],
|
||||||
|
ignore_fields={"latency_ms", "fetched_at"}, # недетерминированные поля
|
||||||
|
tolerance=1e-6, # допуск для float-полей (напр. рассчитанные оценки)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Как формировать `fixtures`
|
||||||
|
|
||||||
|
Каждый элемент списка — один тестовый вход:
|
||||||
|
|
||||||
|
- `tuple`/`list` → распаковывается как позиционные аргументы: `legacy_fn(*fixture)`;
|
||||||
|
- любое другое значение (str, dict, ...) → передаётся как единственный
|
||||||
|
позиционный аргумент: `legacy_fn(fixture)`.
|
||||||
|
|
||||||
|
Начните с 1-2 hardcoded HTML-фикстур/dict'ов (см.
|
||||||
|
`tests/scrapers/test_avito_detail_kit_parity.py` — референсный пример).
|
||||||
|
DB-фикстуры НЕ нужны для чистых parse/compute-функций — используйте их
|
||||||
|
только если сама legacy/kit-функция реально требует Session.
|
||||||
|
|
||||||
|
### Почему нельзя просто `==`
|
||||||
|
|
||||||
|
legacy- и kit-версии одного и того же dataclass (напр. `DetailEnrichment`,
|
||||||
|
`CianValuationResult`) — это РАЗНЫЕ Python-классы (живут в разных модулях),
|
||||||
|
даже если поля идентичны. Дефолтный `dataclass.__eq__` сначала проверяет
|
||||||
|
`other.__class__ is self.__class__` — для двух разных классов это всегда
|
||||||
|
`False`, ДАЖЕ когда все значения полей совпадают. `assert_parity` / `compare_outputs`
|
||||||
|
нормализуют оба вывода в dict/list/scalar (через `dataclasses.fields()` /
|
||||||
|
`.model_dump()` рекурсивно) и сравнивают СТРУКТУРНО, по именам полей — эта
|
||||||
|
проблема класс-идентичности не мешает.
|
||||||
|
|
||||||
|
### ignore_fields vs tolerance
|
||||||
|
|
||||||
|
- `ignore_fields={"latency_ms", "fetched_at", ...}` — поле целиком исключается
|
||||||
|
из сравнения на ЛЮБОМ уровне вложенности. Используйте для полей, у которых
|
||||||
|
даже приблизительное совпадение не гарантировано (timestamps, request-id).
|
||||||
|
- `tolerance=1e-6` — числовой (`int`/`float`, НЕ `bool`) допуск через
|
||||||
|
`math.isclose(rel_tol=tolerance, abs_tol=tolerance)`. Используйте для
|
||||||
|
float-полей, где legacy/kit могут давать чуть разное значение из-за
|
||||||
|
порядка операций с плавающей точкой (не для timestamps/datetime — там
|
||||||
|
используйте `ignore_fields`).
|
||||||
|
|
||||||
|
### При мисматче
|
||||||
|
|
||||||
|
`assert_parity` кидает `ParityMismatchError` (подкласс `AssertionError`) со
|
||||||
|
списком ВСЕХ различающихся полей: путь до поля + значение legacy + значение
|
||||||
|
kit. Не просто "not equal" — сразу видно, что чинить.
|
||||||
|
|
||||||
|
### Не входит в scope harness'а
|
||||||
|
|
||||||
|
- Он НЕ загружает DB-фикстуры сам — если legacy/kit функция требует
|
||||||
|
`Session`, передавайте mock/session в fixture-tuple как обычно.
|
||||||
|
Live network/DB в parity-тестах избегайте — они должны быть
|
||||||
|
detereministic offline unit-тестами.
|
||||||
|
- Он НЕ мигрирует сами importers — это делает каждый sub-issue #2305-#2310
|
||||||
|
отдельно (тесты для конкретной пары legacy/kit функций пишет тот sub-issue).
|
||||||
0
tradein-mvp/backend/tests/support/__init__.py
Normal file
0
tradein-mvp/backend/tests/support/__init__.py
Normal file
178
tradein-mvp/backend/tests/support/parity.py
Normal file
178
tradein-mvp/backend/tests/support/parity.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""Общий 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}"
|
||||||
|
)
|
||||||
119
tradein-mvp/backend/tests/support/test_parity.py
Normal file
119
tradein-mvp/backend/tests/support/test_parity.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""Unit-тесты для parity-harness'а самого по себе (tests/support/parity.py).
|
||||||
|
|
||||||
|
Проверяет три сценария из issue #2304:
|
||||||
|
- legacy/kit выводы идентичны → assert_parity проходит без исключения;
|
||||||
|
- выводы различаются → ParityMismatchError с информативным диффом (не просто "not equal");
|
||||||
|
- tolerance-опция гасит незначащие float-расхождения (напр. latency_ms).
|
||||||
|
|
||||||
|
Также покрывает ignore_fields (второй способ игнорировать недетерминированные поля)
|
||||||
|
и кейс, ради которого harness вообще нужен: dataclass-инстансы РАЗНЫХ классов
|
||||||
|
(имитация legacy vs kit module) с одинаковыми полями должны сравниваться
|
||||||
|
структурно, а не через identity классов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.support.parity import ParityMismatchError, assert_parity, compare_outputs
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_parity_passes_when_outputs_identical() -> None:
|
||||||
|
def legacy_fn(x: int) -> dict[str, int]:
|
||||||
|
return {"value": x * 2}
|
||||||
|
|
||||||
|
def kit_fn(x: int) -> dict[str, int]:
|
||||||
|
return {"value": x * 2}
|
||||||
|
|
||||||
|
assert_parity(legacy_fn, kit_fn, fixtures=[1, 2, 3])
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_parity_raises_with_informative_diff_when_outputs_differ() -> None:
|
||||||
|
def legacy_fn(x: int) -> dict[str, int]:
|
||||||
|
return {"value": x, "count": 10}
|
||||||
|
|
||||||
|
def kit_fn(x: int) -> dict[str, int]:
|
||||||
|
return {"value": x, "count": 999} # намеренное расхождение
|
||||||
|
|
||||||
|
with pytest.raises(ParityMismatchError) as exc_info:
|
||||||
|
assert_parity(legacy_fn, kit_fn, fixtures=[1])
|
||||||
|
|
||||||
|
message = str(exc_info.value)
|
||||||
|
# Диагностика должна называть КОНКРЕТНОЕ поле и оба значения, не просто "not equal".
|
||||||
|
assert "$.count" in message
|
||||||
|
assert "10" in message
|
||||||
|
assert "999" in message
|
||||||
|
# value совпало у обеих функций → не должно попасть в список различий.
|
||||||
|
assert "$.value" not in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_parity_tolerance_ignores_small_float_drift() -> None:
|
||||||
|
def legacy_fn(x: int) -> dict[str, float]:
|
||||||
|
return {"latency_ms": 100.001, "score": 0.5}
|
||||||
|
|
||||||
|
def kit_fn(x: int) -> dict[str, float]:
|
||||||
|
return {"latency_ms": 100.004, "score": 0.5}
|
||||||
|
|
||||||
|
# Без tolerance — расхождение 0.003 ловится.
|
||||||
|
with pytest.raises(ParityMismatchError):
|
||||||
|
assert_parity(legacy_fn, kit_fn, fixtures=[1])
|
||||||
|
|
||||||
|
# С tolerance >= drift — проходит.
|
||||||
|
assert_parity(legacy_fn, kit_fn, fixtures=[1], tolerance=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignore_fields_skips_named_field_entirely() -> None:
|
||||||
|
def legacy_fn(x: int) -> dict[str, object]:
|
||||||
|
return {"fetched_at": "2026-01-01T00:00:00Z", "value": x}
|
||||||
|
|
||||||
|
def kit_fn(x: int) -> dict[str, object]:
|
||||||
|
return {"fetched_at": "2026-07-03T12:00:00Z", "value": x} # timestamp всегда разный
|
||||||
|
|
||||||
|
with pytest.raises(ParityMismatchError):
|
||||||
|
assert_parity(legacy_fn, kit_fn, fixtures=[1])
|
||||||
|
|
||||||
|
assert_parity(legacy_fn, kit_fn, fixtures=[1], ignore_fields={"fetched_at"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataclass_instances_of_different_classes_compared_structurally() -> None:
|
||||||
|
# Имитация legacy vs kit: одинаковые поля, РАЗНЫЕ классы (разные модули).
|
||||||
|
@dataclass
|
||||||
|
class LegacyResult:
|
||||||
|
item_id: str
|
||||||
|
price: int
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KitResult:
|
||||||
|
item_id: str
|
||||||
|
price: int
|
||||||
|
|
||||||
|
legacy = LegacyResult(item_id="42", price=100)
|
||||||
|
kit = KitResult(item_id="42", price=100)
|
||||||
|
|
||||||
|
# Прямое == было бы False (dataclass __eq__ проверяет class identity первым).
|
||||||
|
assert legacy != kit
|
||||||
|
# compare_outputs сравнивает по полям, а не по классу → различий нет.
|
||||||
|
assert compare_outputs(legacy, kit) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataclass_field_mismatch_reported_by_name() -> None:
|
||||||
|
@dataclass
|
||||||
|
class LegacyResult:
|
||||||
|
item_id: str
|
||||||
|
price: int
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KitResult:
|
||||||
|
item_id: str
|
||||||
|
price: int
|
||||||
|
|
||||||
|
legacy = LegacyResult(item_id="42", price=100)
|
||||||
|
kit = KitResult(item_id="42", price=200)
|
||||||
|
|
||||||
|
diffs = compare_outputs(legacy, kit)
|
||||||
|
assert len(diffs) == 1
|
||||||
|
assert "price" in diffs[0]
|
||||||
|
assert "100" in diffs[0]
|
||||||
|
assert "200" in diffs[0]
|
||||||
Loading…
Add table
Reference in a new issue