test(tradein): reusable legacy/scraper_kit parity harness (#2304) #2313
5 changed files with 536 additions and 0 deletions
|
|
@ -0,0 +1,129 @@
|
|||
"""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)
|
||||
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
187
tradein-mvp/backend/tests/support/parity.py
Normal file
187
tradein-mvp/backend/tests/support/parity.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""Общий 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}"
|
||||
)
|
||||
140
tradein-mvp/backend/tests/support/test_parity.py
Normal file
140
tradein-mvp/backend/tests/support/test_parity.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""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_bool_vs_int_type_mismatch_is_not_silently_equal() -> None:
|
||||
# Python bool — подкласс int: `True == 1` и `False == 0` дают True при обычном
|
||||
# `==`. Миграция, случайно превратившая bool-поле в сырой 0/1, должна ловиться.
|
||||
def legacy_fn(x: int) -> dict[str, object]:
|
||||
return {"mortgage_available": True}
|
||||
|
||||
def kit_fn(x: int) -> dict[str, object]:
|
||||
return {"mortgage_available": 1} # намеренно int вместо bool
|
||||
|
||||
with pytest.raises(ParityMismatchError) as exc_info:
|
||||
assert_parity(legacy_fn, kit_fn, fixtures=[1])
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "mortgage_available" in message
|
||||
assert "bool" in message
|
||||
|
||||
# Обратный случай (False vs 0) и совпадающие типы (bool vs bool) — симметрично.
|
||||
assert compare_outputs({"v": False}, {"v": 0}) != []
|
||||
assert compare_outputs({"v": True}, {"v": True}) == []
|
||||
|
||||
|
||||
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