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.
140 lines
5.4 KiB
Python
140 lines
5.4 KiB
Python
"""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]
|