Compare commits
No commits in common. "d95c9e6e552d4c291e8b8ceaf741f78770595e61" and "a8b504c3de3070860753780c4a3640917e93e79c" have entirely different histories.
d95c9e6e55
...
a8b504c3de
5 changed files with 0 additions and 536 deletions
|
|
@ -1,129 +0,0 @@
|
||||||
"""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)
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
# 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).
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
"""Общий 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}"
|
|
||||||
)
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
"""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