diff --git a/changelog/13652.bugfix.rst b/changelog/13652.bugfix.rst new file mode 100644 index 00000000000..12ff991a796 --- /dev/null +++ b/changelog/13652.bugfix.rst @@ -0,0 +1 @@ +``contains one more item`` now reports the actual extra item in sequence diffs. diff --git a/src/_pytest/assertion/_compare_sequence.py b/src/_pytest/assertion/_compare_sequence.py index cada856cc85..e38242a0e4f 100644 --- a/src/_pytest/assertion/_compare_sequence.py +++ b/src/_pytest/assertion/_compare_sequence.py @@ -43,6 +43,17 @@ def _compare_eq_iterable( yield highlighter(line.rstrip(), lexer="diff") +def _tails_align( + left: Sequence[object], + right: Sequence[object], +) -> bool: + # Exotic __eq__ may return non-bools or raise; fall back instead. + try: + return bool(left == right) + except Exception: + return False + + def _compare_eq_sequence( left: Sequence[object], right: Sequence[object], @@ -52,6 +63,8 @@ def _compare_eq_sequence( comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes) len_left = len(left) len_right = len(right) + found_diff = False + i = -1 for i in range(min(len_left, len_right)): if left[i] != right[i]: if comparing_bytes: @@ -73,6 +86,7 @@ def _compare_eq_sequence( f"At index {i} diff:" f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}" ) + found_diff = True break if comparing_bytes: @@ -84,11 +98,20 @@ def _compare_eq_sequence( if len_diff: if len_diff > 0: dir_with_more = "Left" - extra = saferepr(left[len_right]) + # If the longer side has exactly one extra item and the tails after + # the first differing index align (offset by one), that item is the + # insertion. + if found_diff and len_diff == 1 and _tails_align(left[i + 1 :], right[i:]): + extra = saferepr(left[i]) + else: + extra = saferepr(left[len_right]) else: len_diff = 0 - len_diff dir_with_more = "Right" - extra = saferepr(right[len_left]) + if found_diff and len_diff == 1 and _tails_align(right[i + 1 :], left[i:]): + extra = saferepr(right[i]) + else: + extra = saferepr(right[len_left]) if len_diff == 1: yield f"{dir_with_more} contains one more item: {highlighter(extra)}" diff --git a/testing/test_assertion.py b/testing/test_assertion.py index cc2ea761e81..726dc739211 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -4,6 +4,7 @@ from collections.abc import Iterator from collections.abc import Mapping from collections.abc import MutableSequence +from collections.abc import Sequence import dataclasses import sys import textwrap @@ -1105,6 +1106,63 @@ def test_sequence_different_items(self) -> None: " )", ] + @pytest.mark.parametrize( + ("left", "right", "expected"), + [ + ([], [1], "Right contains one more item: 1"), + ([1], [], "Left contains one more item: 1"), + ([1, 2, 3], [0, 1, 2, 3], "Right contains one more item: 0"), + ([1, 2, 3], [1, 2, 3, 4], "Right contains one more item: 4"), + ([1, 2, 3], [1, 2, 0, 3], "Right contains one more item: 0"), + ([1, 2, 0, 3], [1, 2, 3], "Left contains one more item: 0"), + ([1, 1], [1, 0, 1], "Right contains one more item: 0"), + ([3, 4, 5], [1, 2], "Left contains one more item: 5"), + # Fallback: single extra item but tails don't align + ([1, 2, 3], [1, 9, 8, 3], "Right contains one more item: 3"), + ([1, 9, 8, 3], [1, 2, 3], "Left contains one more item: 3"), + ( + [1, 2, 3], + [0, 1, 2, 3, 4], + "Right contains 2 more items, first extra item: 3", + ), + ], + ) + def test_sequence_extra_item_message( + self, left: list[object], right: list[object], expected: str + ) -> None: + lines = callequal(left, right, verbose=1) + assert lines is not None + assert expected in lines + + def test_sequence_extra_item_exotic_eq_falls_back(self) -> None: + """Exotic elementwise __eq__ must not break rendering (fallback).""" + + class Exotic(Sequence[int]): + def __init__(self, iterable): + self.elements = list(iterable) + + def __getitem__(self, item): + result = self.elements[item] + return Exotic(result) if isinstance(item, slice) else result + + def __len__(self): + return len(self.elements) + + def __eq__(self, other): + if isinstance(other, Exotic): + result = [ + a == b + for a, b in zip(self.elements, other.elements, strict=False) + ] + if len(result) != 1: + raise ValueError("ambiguous, like numpy") + return result[0] + return NotImplemented + + lines = callequal(Exotic([1, 2, 3, 4]), Exotic([1, 9, 2, 3, 4]), verbose=1) + assert lines is not None + assert "Right contains one more item: 4" in lines + def test_set(self) -> None: expl = callequal({0, 1}, {0, 2}) assert expl is not None diff --git a/testing/test_error_diffs.py b/testing/test_error_diffs.py index 653559c8a99..78e9bba3437 100644 --- a/testing/test_error_diffs.py +++ b/testing/test_error_diffs.py @@ -66,7 +66,7 @@ def test_this(): > assert result == expected E assert [1, 3] == [1, 2, 3] E At index 1 diff: 3 != 2 - E Right contains one more item: 3 + E Right contains one more item: 2 E Full diff: (-: missing in left side, +: extra in left side) E [ E 1, @@ -76,6 +76,50 @@ def test_this(): """, id="Compare lists, one item missing", ), + pytest.param( + """ + def test_this(): + result = [1, 2, 3] + expected = [1, 2, 0, 3] + assert result == expected + """, + """ + > assert result == expected + E assert [1, 2, 3] == [1, 2, 0, 3] + E At index 2 diff: 3 != 0 + E Right contains one more item: 0 + E Full diff: (-: missing in left side, +: extra in left side) + E [ + E 1, + E 2, + E - 0, + E 3, + E ] + """, + id="Compare lists, one extra item inserted mid-list", + ), + pytest.param( + """ + def test_this(): + result = [1, 2, 0, 3] + expected = [1, 2, 3] + assert result == expected + """, + """ + > assert result == expected + E assert [1, 2, 0, 3] == [1, 2, 3] + E At index 2 diff: 0 != 3 + E Left contains one more item: 0 + E Full diff: (-: missing in left side, +: extra in left side) + E [ + E 1, + E 2, + E + 0, + E 3, + E ] + """, + id="Compare lists, one extra item inserted mid-list on left", + ), pytest.param( """ def test_this():