fix(tradein/tests): harden parity harness — bool-type mismatch detection + divergence-catch proof (#2304)
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m34s
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

Code-reviewer follow-up on the parity harness (aaaf8179):

- parity.py: plain `==` in the scalar-equality branch let `True == 1` /
  `False == 0` silently pass (Python bool is an int subclass). A future
  migration bug turning a `bool | None` field into a raw 0/1 would slip
  through undetected. Now any type mismatch where exactly one side is a
  bool is reported as a diff, regardless of numeric equality.
- test_parity.py: unit test for the new bool-vs-int branch.
- test_avito_detail_kit_parity.py: the existing smoke test only proved the
  harness reports "no diff" on two genuinely-identical real dataclass
  instances — it never proved the harness catches a real divergence on
  this same 30+-field shape (only the toy fixtures in test_parity.py did).
  Added a test that mutates `price_rub` via dataclasses.replace() on the
  real kit DetailEnrichment and asserts assert_parity raises
  ParityMismatchError naming that field.
This commit is contained in:
bot-backend 2026-07-03 23:45:18 +03:00
parent 57963e3a06
commit d95c9e6e55
3 changed files with 58 additions and 1 deletions

View file

@ -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 = """
<html><body>
@ -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)

View file

@ -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):

View file

@ -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: