From e3fd0db7451d75c2b1f5e2cea12ae376a4cf6c74 Mon Sep 17 00:00:00 2001 From: Berend Klein Haneveld Date: Tue, 26 May 2026 11:41:39 +0200 Subject: [PATCH] Pin ListProxy.reverse patch-count semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests that lock in the current N-patch shape (and N-1 for odd N, since record_replace already filters the middle no-op), plus round-trip coverage for n in {0,1,2,3,4,5,10}. No behavior change. Opening this as a draft to surface the design question: a comment in the existing code calls the per-position replaces "redundant", but with JSON Patch replace semantics each position genuinely needs its own patch — dropping half leaves those slots untouched on apply. See PR body for the analysis. Co-Authored-By: Claude Opus 4.7 --- tests/test_produce_list.py | 56 +++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/test_produce_list.py b/tests/test_produce_list.py index cd11301..456607f 100644 --- a/tests/test_produce_list.py +++ b/tests/test_produce_list.py @@ -2,7 +2,7 @@ import pytest -from patchdiff import produce +from patchdiff import apply, produce from patchdiff.pointer import Pointer @@ -868,6 +868,60 @@ def recipe(draft): assert len(patches) == 0 +def test_list_reverse_emits_n_patches_for_even_n(): + """Pin current semantics: reverse emits exactly N replace patches for an + N-element list of distinct values when N is even. + + With ``replace`` semantics every position must be written explicitly — + dropping the patches for one half of each swap pair would leave those + positions untouched on apply. See PR discussion for the trade-off. + """ + base = [1, 2, 3, 4] + + def recipe(draft): + draft.reverse() + + _result, patches, _reverse = produce(base, recipe) + + assert len(patches) == 4 + assert all(p["op"] == "replace" for p in patches) + assert {p["path"] for p in patches} == {Pointer([i]) for i in range(4)} + + +def test_list_reverse_emits_n_minus_one_patches_for_odd_n(): + """Pin current semantics: for odd N the middle element is unchanged and + record_replace filters that no-op, so we see N-1 patches.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + draft.reverse() + + _result, patches, _reverse = produce(base, recipe) + + assert len(patches) == 4 + assert {p["path"] for p in patches} == { + Pointer([0]), + Pointer([1]), + Pointer([3]), + Pointer([4]), + } + + +@pytest.mark.parametrize("n", [0, 1, 2, 3, 4, 5, 10]) +def test_list_reverse_round_trip(n): + """Forward and reverse patches both round-trip across a range of sizes.""" + base = list(range(n)) + + def recipe(draft): + draft.reverse() + + result, patches, reverse = produce(base, recipe) + + assert result == list(reversed(base)) + assert apply(base, patches) == result + assert apply(result, reverse) == base + + def test_list_sort_empty(): """Test sort() on empty list.""" base = []