diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_detail_kit_parity.py b/tradein-mvp/backend/tests/scrapers/test_avito_detail_kit_parity.py new file mode 100644 index 00000000..7c0f2e3b --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_avito_detail_kit_parity.py @@ -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 = """ + +
№ 7986882804 · 18 мая в 13:40 · 1899 просмотров (+ 12 сегодня)
+11 990 000 ₽ +
+
+ + +
+Дом +
+

1 собственник

+

Последняя смена собственника 5 мая 2020

+

Не найдены ограничения и обременения

+

Совпадают площадь, адрес и этаж

+
+
Отличная квартира в новом ЖК. Чкаловская 11-15 мин пешком.
+ +""" +_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) diff --git a/tradein-mvp/backend/tests/support/README.md b/tradein-mvp/backend/tests/support/README.md new file mode 100644 index 00000000..1b48c877 --- /dev/null +++ b/tradein-mvp/backend/tests/support/README.md @@ -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). diff --git a/tradein-mvp/backend/tests/support/__init__.py b/tradein-mvp/backend/tests/support/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tradein-mvp/backend/tests/support/parity.py b/tradein-mvp/backend/tests/support/parity.py new file mode 100644 index 00000000..c1864b8c --- /dev/null +++ b/tradein-mvp/backend/tests/support/parity.py @@ -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}" + ) diff --git a/tradein-mvp/backend/tests/support/test_parity.py b/tradein-mvp/backend/tests/support/test_parity.py new file mode 100644 index 00000000..c1c41c1e --- /dev/null +++ b/tradein-mvp/backend/tests/support/test_parity.py @@ -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]