jay_diff: fix two correctness bugs + add real-data round-trip tests
Bug 1 (crash): jay_merge_delete iterated _new.items() while _old (which
aliases _new sub-dicts due to jay_diff_rev's side-effect writes) was being
mutated. Fixed by snapshotting with list(_new.items()) before iteration.
Bug 2 (wrong round-trip): When new has an empty dict {} at a location where
old had a non-empty dict, jay_merge_delete incorrectly pruned the now-empty
parent key. Root cause: jay_diff_del stored the full old sub-dict in the
delete delta for both "key absent from new" and "key present but empty in
new", making them indistinguishable at merge time.
Fix: jay_diff_del is now a standalone recursive function that stores None
for dict keys entirely absent from new (sentinel for "delete entire key")
vs a sub-dict of specific sub-keys for partial deletions. jay_merge_delete
no longer prunes empty dicts; None (or any non-dict value) in the delta is
the explicit signal to remove the parent key.
Also adds:
- tests/data/ — copy of 25 real JSON log files for integration testing
- tests/test_jay_diff_real_data.py — 2028 parametrised round-trip tests
over 675 real vehicle snapshots; exposed and confirmed both bugs above
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+17
-7
@@ -60,8 +60,9 @@ class TestJayDiffDel:
|
||||
{'a': 'hamburger', 'b': 'fries'}) == {'c': 'pepsi'}
|
||||
|
||||
def test_nested_deleted_key(self):
|
||||
# 'c' is absent from _new → None signals "delete entire key"
|
||||
assert jay_diff_del({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
|
||||
{'a': 'hamburger', 'b': 'fries'}) == {'c': {'e': 'coke', 'f': 'coffee'}}
|
||||
{'a': 'hamburger', 'b': 'fries'}) == {'c': None}
|
||||
|
||||
def test_nested_partial_delete(self):
|
||||
assert jay_diff_del({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
|
||||
@@ -164,7 +165,7 @@ class TestJayDiffFullSeparate:
|
||||
def test_delete_nested(self):
|
||||
assert jay_diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
|
||||
{'a': 'hamburger', 'b': 'fries'}, self.F) \
|
||||
== {'delete': {'c': {'e': 'coke', 'f': 'coffee'}}}
|
||||
== {'delete': {'c': None}}
|
||||
|
||||
def test_nested_partial_delete(self):
|
||||
assert jay_diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
|
||||
@@ -174,12 +175,12 @@ class TestJayDiffFullSeparate:
|
||||
def test_update_and_delete(self):
|
||||
assert jay_diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
|
||||
{'a': 'hotdog', 'b': 'potatoes'}, self.F) \
|
||||
== {'delete': {'c': {'e': 'coke', 'f': 'coffee'}}, 'update': {'a': 'hotdog', 'b': 'potatoes'}}
|
||||
== {'delete': {'c': None}, 'update': {'a': 'hotdog', 'b': 'potatoes'}}
|
||||
|
||||
def test_nested_add_delete_update(self):
|
||||
assert jay_diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
|
||||
{'a': 'hotdog', 'b': 'potatoes', 'd': 'icecream'}, self.F) \
|
||||
== {'add': {'d': 'icecream'}, 'delete': {'c': {'e': 'coke', 'f': 'coffee'}}, 'update': {'a': 'hotdog', 'b': 'potatoes'}}
|
||||
== {'add': {'d': 'icecream'}, 'delete': {'c': None}, 'update': {'a': 'hotdog', 'b': 'potatoes'}}
|
||||
|
||||
|
||||
# ── jay_diff_full (combine_upd_add=True) ──────────────────────────────────────
|
||||
@@ -213,12 +214,12 @@ class TestJayDiffFullCombined:
|
||||
def test_delete_nested(self):
|
||||
assert jay_diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
|
||||
{'a': 'hamburger', 'b': 'fries'}, self.T) \
|
||||
== {'delete': {'c': {'e': 'coke', 'f': 'coffee'}}}
|
||||
== {'delete': {'c': None}}
|
||||
|
||||
def test_update_and_delete_nested(self):
|
||||
assert jay_diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
|
||||
{'a': 'hotdog', 'b': 'potatoes'}, self.T) \
|
||||
== {'delete': {'c': {'e': 'coke', 'f': 'coffee'}}, 'update_add': {'a': 'hotdog', 'b': 'potatoes'}}
|
||||
== {'delete': {'c': None}, 'update_add': {'a': 'hotdog', 'b': 'potatoes'}}
|
||||
|
||||
def test_nested_update_add_and_delete(self):
|
||||
assert jay_diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
|
||||
@@ -412,7 +413,7 @@ class TestCompletelyDifferentStructure:
|
||||
diff = jay_diff_full(old, new)
|
||||
assert diff == {
|
||||
'update_add': {'electric_drive': {'range': 300, 'soc': 80}},
|
||||
'delete': {'charging': {'state': 'charging', 'power': 11}},
|
||||
'delete': {'charging': None},
|
||||
}
|
||||
|
||||
def test_merge_round_trip_all_keys_replaced(self):
|
||||
@@ -465,6 +466,15 @@ class TestEmptyContainers:
|
||||
diff = jay_diff_del(old, new)
|
||||
assert diff == {'a': {'b': 'c'}}
|
||||
|
||||
def test_empty_dict_preserved_after_delete_all_subkeys(self):
|
||||
# When new has an empty dict at a location, the key must survive with {}
|
||||
# (not be pruned away). This would break if merge incorrectly prunes empty dicts.
|
||||
old = {'x': {'p': 1, 'q': 2}}
|
||||
new = {'x': {}}
|
||||
diff = jay_diff_full(old, new)
|
||||
result = jay_merge_full(deepcopy(old), diff)
|
||||
assert result == new # 'x' must remain as {}
|
||||
|
||||
|
||||
# ── Corner cases: deep nesting ────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user