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
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:
parent
57963e3a06
commit
d95c9e6e55
3 changed files with 58 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue