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 index f0b752ff..7c0f2e3b 100644 --- a/tradein-mvp/backend/tests/scrapers/test_avito_detail_kit_parity.py +++ b/tradein-mvp/backend/tests/scrapers/test_avito_detail_kit_parity.py @@ -29,6 +29,8 @@ 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 — тест должен жить и без @@ -42,7 +44,7 @@ from scraper_kit.providers.avito.detail import ( from app.services.scrapers.avito_detail import ( parse_detail_html as legacy_parse_detail_html, ) -from tests.support.parity import assert_parity +from tests.support.parity import ParityMismatchError, assert_parity _DETAIL_HTML = """ @@ -100,3 +102,28 @@ def test_parity_harness_confirms_avito_detail_parse_equivalence() -> None: 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/parity.py b/tradein-mvp/backend/tests/support/parity.py index f4927a3b..c1864b8c 100644 --- a/tradein-mvp/backend/tests/support/parity.py +++ b/tradein-mvp/backend/tests/support/parity.py @@ -111,6 +111,15 @@ def _diff( _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): diff --git a/tradein-mvp/backend/tests/support/test_parity.py b/tradein-mvp/backend/tests/support/test_parity.py index b1d47f81..c1c41c1e 100644 --- a/tradein-mvp/backend/tests/support/test_parity.py +++ b/tradein-mvp/backend/tests/support/test_parity.py @@ -98,6 +98,27 @@ def test_dataclass_instances_of_different_classes_compared_structurally() -> Non 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: