refactored

This commit is contained in:
2026-05-27 13:33:52 +02:00
parent 4f98971ab3
commit ddf0cc54ce
31 changed files with 544 additions and 538 deletions
View File
+211
View File
@@ -0,0 +1,211 @@
# ── Corner cases: data types ───────────────────────────────────────────────────
import pytest
from copy import deepcopy
from jaydiff.diff import diff_full, diff_add, diff_del, diff_upd, diff_upd_add
from jaydiff.merge import merge_add, merge_delete, merge_update, merge_full
class TestNumericAndScalarTypes:
def test_diff_int_value_changed(self):
assert diff_upd({'a': 1, 'b': 2}, {'a': 1, 'b': 3}) == {'b': 3}
def test_diff_float_value_changed(self):
assert diff_upd({'a': 1.0}, {'a': 1.1}) == {'a': 1.1}
def test_diff_int_unchanged(self):
assert diff_upd({'a': 42}, {'a': 42}) == {}
def test_diff_bool_changed(self):
assert diff_upd({'a': True}, {'a': False}) == {'a': False}
def test_diff_bool_false_to_true(self):
assert diff_upd({'online': False}, {'online': True}) == {'online': True}
def test_diff_none_value_changed(self):
assert diff_upd({'a': None}, {'a': 'something'}) == {'a': 'something'}
def test_diff_value_to_none(self):
assert diff_upd({'a': 'something'}, {'a': None}) == {'a': None}
def test_diff_list_unchanged(self):
assert diff_upd({'a': [1, 2, 3]}, {'a': [1, 2, 3]}) == {}
def test_diff_list_changed(self):
assert diff_upd({'a': [1, 2]}, {'a': [1, 2, 3]}) == {'a': [1, 2, 3]}
def test_add_numeric_key(self):
assert diff_add({'a': 1}, {'a': 1, 'b': 2}) == {'b': 2}
def test_full_combined_numeric(self):
old = {'soc': 80, 'range': 300, 'temp': 22.5}
new = {'soc': 75, 'range': 280, 'temp': 22.5}
diff = diff_full(old, new)
assert diff == {'update_add': {'soc': 75, 'range': 280}}
def test_merge_numeric_update(self):
assert merge_full({'soc': 80, 'range': 300}, {'update_add': {'soc': 75}}) \
== {'soc': 75, 'range': 300}
# ── Corner cases: type changes (dict ↔ scalar) ────────────────────────────────
class TestTypeChanges:
def test_dict_replaced_by_scalar_in_diff(self):
# A key that was a dict becomes a scalar — upd treats the whole value as changed
old = {'a': {'b': 'c'}}
new = {'a': 'flat'}
diff = diff_upd(old, new)
assert diff == {'a': 'flat'}
def test_merge_dict_to_scalar(self):
old = {'a': {'b': 'c'}, 'x': 1}
diff = {'update_add': {'a': 'flat'}}
result = merge_full(old, diff)
assert result == {'a': 'flat', 'x': 1}
def test_diff_full_dict_to_scalar_combined(self):
old = {'status': {'value': 42}}
new = {'status': 'unknown'}
diff = diff_full(old, new)
result = merge_full(deepcopy(old), diff)
assert result == new
def test_diff_add_new_nested_structure(self):
# Key absent in old, present as dict in new — treated as addition
old = {'a': 1}
new = {'a': 1, 'b': {'c': 2}}
diff = diff_add(old, new)
assert diff == {'b': {'c': 2}}
# ── Corner cases: completely different structures ─────────────────────────────
class TestCompletelyDifferentStructure:
def test_all_keys_replaced(self):
old = {'a': 1, 'b': 2}
new = {'x': 10, 'y': 20}
diff = diff_full(old, new)
assert diff == {'update_add': {'x': 10, 'y': 20}, 'delete': {'a': 1, 'b': 2}}
def test_all_keys_replaced_deep(self):
old = {'charging': {'state': 'charging', 'power': 11}}
new = {'electric_drive': {'range': 300, 'soc': 80}}
diff = diff_full(old, new)
assert diff == {
'update_add': {'electric_drive': {'range': 300, 'soc': 80}},
'delete': {'charging': None},
}
def test_merge_round_trip_all_keys_replaced(self):
old = {'a': 1, 'b': 2}
new = {'x': 10, 'y': 20}
diff = diff_full(old, new)
result = merge_full(deepcopy(old), diff)
assert result == new
def test_diff_disjoint_no_update(self):
# No shared keys → no updates, only add/delete
old = {'a': 1}
new = {'b': 2}
diff = diff_upd(old, new)
assert diff == {}
def test_diff_disjoint_add(self):
assert diff_add({'a': 1}, {'b': 2}) == {'b': 2}
def test_diff_disjoint_del(self):
assert diff_del({'a': 1}, {'b': 2}) == {'a': 1}
# ── Corner cases: empty containers ────────────────────────────────────────────
class TestEmptyContainers:
def test_diff_both_empty(self):
assert diff_full({}, {}) == {}
def test_diff_old_empty_new_has_data(self):
diff = diff_full({}, {'a': 1})
assert diff == {'update_add': {'a': 1}}
def test_diff_new_empty_old_has_data(self):
diff = diff_full({'a': 1}, {})
assert diff == {'delete': {'a': 1}}
def test_merge_delete_all_keys(self):
result = merge_full({'a': 1, 'b': 2}, {'delete': {'a': 1, 'b': 2}})
assert result == {}
def test_delete_empty_dict_value(self):
# Deleting a key whose value is {} — should not recurse (fixed bug)
result = merge_delete({'a': 'keep', 'b': {}}, {'b': {}})
assert result == {'a': 'keep'}
def test_diff_nested_empty_dict(self):
old = {'a': {'b': 'c'}}
new = {'a': {}}
diff = 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 = diff_full(old, new)
result = merge_full(deepcopy(old), diff)
assert result == new # 'x' must remain as {}
# ── Corner cases: deep nesting ────────────────────────────────────────────────
class TestDeepNesting:
def test_diff_three_levels(self):
old = {'l1': {'l2': {'l3': 'old'}}}
new = {'l1': {'l2': {'l3': 'new'}}}
assert diff_upd(old, new) == {'l1': {'l2': {'l3': 'new'}}}
def test_merge_three_levels(self):
old = {'l1': {'l2': {'l3': 'old', 'keep': 'yes'}}}
diff = {'update_add': {'l1': {'l2': {'l3': 'new'}}}}
result = merge_full(deepcopy(old), diff)
assert result == {'l1': {'l2': {'l3': 'new', 'keep': 'yes'}}}
def test_diff_full_three_levels(self):
old = {'a': {'b': {'c': {'d': 'deep'}}}}
new = {'a': {'b': {'c': {'d': 'deeper'}}}}
diff = diff_full(old, new)
result = merge_full(deepcopy(old), diff)
assert result == new
# ── Round-trip: merge(old, diff(old, new)) == new ────────────────────────────
class TestRoundTrip:
"""Verify that applying a diff to old always produces new."""
@pytest.mark.parametrize("old,new", [
({'a': 1}, {'a': 2}),
({'a': 1, 'b': 2}, {'b': 3, 'c': 4}),
({'x': {'y': 'old'}}, {'x': {'y': 'new', 'z': 'added'}}),
({'a': {'b': 1}, 'c': 2}, {'a': {'b': 1}, 'd': 3}),
({'soc': 80, 'range': 300}, {'soc': 60, 'range': 240, 'temp': 25}),
({'a': True, 'b': None}, {'a': False, 'c': 42}),
({'l1': {'l2': {'v': 1}}}, {'l1': {'l2': {'v': 2}}}),
({}, {'a': 1}),
({'a': 1}, {}),
])
def test_round_trip_combined(self, old, new):
diff = diff_full(deepcopy(old), deepcopy(new), combine_upd_add=True)
result = merge_full(deepcopy(old), diff)
assert result == new
@pytest.mark.parametrize("old,new", [
({'a': 1}, {'a': 2}),
({'a': 1, 'b': 2}, {'b': 3, 'c': 4}),
({'x': {'y': 'old'}}, {'x': {'y': 'new', 'z': 'added'}}),
({}, {'a': 1}),
({'a': 1}, {}),
])
def test_round_trip_separate(self, old, new):
diff = diff_full(deepcopy(old), deepcopy(new), combine_upd_add=False)
result = merge_full(deepcopy(old), diff)
assert result == new
+232
View File
@@ -0,0 +1,232 @@
"""Tests for diff."""
import pytest
from copy import deepcopy
from jaydiff.diff import diff_full, diff_add, diff_del, diff_upd, diff_upd_add
# ── diff_add ──────────────────────────────────────────────────────────────
class TestJayDiffAdd:
def test_add_to_empty(self):
assert diff_add({}, {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}
def test_no_new_keys(self):
assert diff_add({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}) == {}
def test_old_has_extra_keys(self):
assert diff_add({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi', 'd': 'icecream'},
{'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}) == {}
def test_one_new_key(self):
assert diff_add({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}) == {'d': 'icecream'}
def test_new_key_among_overlap(self):
assert diff_add({'a': 'hamburger', 'c': 'pepsi', 'd': 'icecream'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}) == {'b': 'fries'}
def test_nested_new_key(self):
assert diff_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke'}},
{'c': {'f': 'coffee'}}) == {'c': {'f': 'coffee'}}
def test_nested_no_new_keys(self):
assert diff_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}}) == {}
# ── diff_del ──────────────────────────────────────────────────────────────
class TestJayDiffDel:
def test_one_deleted_key(self):
assert diff_del({'a': 'hamburger', 'c': 'pepsi', 'd': 'icecream'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}) == {'d': 'icecream'}
def test_no_deleted_keys_same_values(self):
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': 'coke'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}) == {}
def test_no_deleted_keys_different_values(self):
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': 'coke'},
{'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}) == {}
def test_key_absent_from_new(self):
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'b': 'fries'}) == {'c': 'pepsi'}
def test_nested_deleted_key(self):
# 'c' is absent from _new → None signals "delete entire key"
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries'}) == {'c': None}
def test_nested_partial_delete(self):
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'e': 'coke'}}) == {'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}}
def test_nested_no_delete_different_value(self):
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'e': 'pepsi', 'f': 'coffee'}}) == {}
# ── diff_upd ──────────────────────────────────────────────────────────────
class TestJayDiffUpd:
def test_multiple_updates(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'potatoes'}) == {'a': 'hotdog', 'b': 'potatoes'}
def test_nested_update(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'e': 'pepsi'}}) == {'c': {'e': 'pepsi'}}
def test_one_update(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'fries'}) == {'a': 'hotdog'}
def test_no_update_new_key_ignored(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}) == {'c': 'coke'}
def test_nested_no_update_new_subkey_ignored(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke'}},
{'c': {'f': 'coffee'}}) == {}
def test_nested_update_one_of_two(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'e': 'pepsi', 'f': 'coffee'}}) \
== {'c': {'e': 'pepsi'}}
# ── diff_upd_add ──────────────────────────────────────────────────────────
class TestJayDiffUpdAdd:
def test_update_and_add(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'potatoes', 'd': 'icecream'}) \
== {'a': 'hotdog', 'b': 'potatoes', 'd': 'icecream'}
def test_nested_update_and_add(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'e': 'pepsi', 'g': 'tea', 'h': 'cake'}, 'b': 'potatoes'}) \
== {'b': 'potatoes', 'c': {'e': 'pepsi', 'g': 'tea', 'h': 'cake'}}
def test_update_only(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'fries'}) == {'a': 'hotdog'}
def test_nested_update_and_new_subkey(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'e': 'pepsi', 'f': 'coffee', 'h': 'cake'}}) \
== {'c': {'e': 'pepsi', 'h': 'cake'}}
def test_add_new_top_and_update(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}) \
== {'c': 'coke', 'd': 'icecream'}
def test_identical(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries'},
{'a': 'hamburger', 'b': 'fries'}) == {}
# ── diff_full (combine_upd_add=False) ─────────────────────────────────────
class TestJayDiffFullSeparate:
F = False
def test_add_all(self):
assert diff_full({}, {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}, self.F) \
== {'add': {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}}
def test_update_one(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, self.F) \
== {'update': {'c': 'coke'}}
def test_delete_one(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi', 'd': 'icecream'},
{'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}, self.F) \
== {'delete': {'d': 'icecream'}}
def test_add_delete_update(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}, self.F) \
== {'add': {'d': 'icecream'}, 'delete': {'b': 'fries'}, 'update': {'c': 'coke'}}
def test_identical(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'coke'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, self.F) == {}
def test_delete_nested(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries'}, self.F) \
== {'delete': {'c': None}}
def test_nested_partial_delete(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}}, self.F) \
== {'delete': {'c': {'e': 'coke'}}}
def test_update_and_delete(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'potatoes'}, self.F) \
== {'delete': {'c': None}, 'update': {'a': 'hotdog', 'b': 'potatoes'}}
def test_nested_add_delete_update(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'potatoes', 'd': 'icecream'}, self.F) \
== {'add': {'d': 'icecream'}, 'delete': {'c': None}, 'update': {'a': 'hotdog', 'b': 'potatoes'}}
# ── diff_full (combine_upd_add=True) ──────────────────────────────────────
class TestJayDiffFullCombined:
T = True
def test_add_all(self):
assert diff_full({}, {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}, self.T) \
== {'update_add': {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}}
def test_update_one(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, self.T) \
== {'update_add': {'c': 'coke'}}
def test_delete_one(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi', 'd': 'icecream'},
{'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}, self.T) \
== {'delete': {'d': 'icecream'}}
def test_delete_and_update_add(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}, self.T) \
== {'delete': {'b': 'fries'}, 'update_add': {'c': 'coke', 'd': 'icecream'}}
def test_identical(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'coke'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, self.T) == {}
def test_delete_nested(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries'}, self.T) \
== {'delete': {'c': None}}
def test_update_and_delete_nested(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'potatoes'}, self.T) \
== {'delete': {'c': None}, 'update_add': {'a': 'hotdog', 'b': 'potatoes'}}
def test_nested_update_add_and_delete(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'e': 'pepsi', 'g': 'tea', 'h': 'cake'}, 'b': 'potatoes'}, self.T) \
== {'delete': {'a': 'hamburger', 'c': {'f': 'coffee'}},
'update_add': {'b': 'potatoes', 'c': {'e': 'pepsi', 'g': 'tea', 'h': 'cake'}}}
def test_nested_add_subkey(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'e': 'pepsi', 'f': 'coffee', 'h': 'cake'}}, self.T) \
== {'update_add': {'c': {'e': 'pepsi', 'h': 'cake'}}}
+93
View File
@@ -0,0 +1,93 @@
import pytest
from jaydiff.merge import merge_add, merge_delete, merge_update, merge_full
# ── merge_* ───────────────────────────────────────────────────────────────
class TestJayMerge:
def test_merge_add_to_empty(self):
assert merge_add({}, {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}
def test_merge_add_new_key(self):
assert merge_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'d': 'icecream'}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}, 'd': 'icecream'}
def test_merge_add_existing_unchanged(self):
assert merge_add({'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}, 'd': 'icecream'},
{'d': 'icecream'}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}, 'd': 'icecream'}
def test_merge_delete_top_key(self):
assert merge_delete({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}, 'd': 'icecream'},
{'d': 'icecream'}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}
def test_merge_delete_nested_key(self):
assert merge_delete({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'f': 'coffee'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke'}}
def test_merge_delete_nested_leaves_sibling(self):
assert merge_delete({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'e': 'coke'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}}
class TestJayMergeFull:
def test_passthrough_when_old_empty(self):
assert merge_full({}, {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}
def test_empty_diff_unchanged(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, {}) \
== {'a': 'hamburger', 'b': 'fries', 'c': 'coke'}
def test_update(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, {'update': {'c': 'pepsi'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}
def test_update_add(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}, {'update_add': {'c': 'coke'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': 'coke'}
def test_delete(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi', 'd': 'icecream'},
{'delete': {'d': 'icecream'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}
def test_add_delete_update_combined(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'add': {'d': 'icecream'}, 'delete': {'b': 'fries'}, 'update': {'c': 'coke'}}) \
== {'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}
def test_nested_update_and_delete(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'delete': {'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}},
'update': {'c': {'e': 'pepsi'}}}) \
== {'c': {'e': 'pepsi'}}
def test_update_add_new_subkey(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'update_add': {'c': {'e': 'pepsi', 'h': 'cake'}}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'pepsi', 'f': 'coffee', 'h': 'cake'}}
def test_real_world_snapshot_update(self):
old = {'charging': {
'batteryStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:34Z', 'cruisingRangeElectric_km': 192}},
'chargingStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:34Z'}},
'chargingSettings': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:30Z'}},
'plugStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:29Z'}}}}
update = {'charging': {
'batteryStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:35:46Z', 'cruisingRangeElectric_km': 191}},
'chargingStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:35:46Z'}}}}
add = {'charging': {'batteryStatus': {'value': {'currentSOC_pct': 55}}},
'fuelStatus': {'rangeStatus': {'value': {'primaryEngine': {'currentSOC_pct': 55}}}},
'measurements': {'fuelLevelStatus': {'value': {'currentSOC_pct': 55}}}}
delete = {'charging': {
'chargingSettings': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:30Z'}},
'plugStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:29Z'}}}}
result = merge_full(old, {'update': update, 'add': add, 'delete': delete})
assert result == old
@@ -3,7 +3,7 @@ Round-trip tests for diff using real snapshot records from ./tests/data/.
For every pair of consecutive records (within a file and across adjacent files)
we verify:
jay_merge_full(a, jay_diff_full(a, b)) == b
merge_full(a, diff_full(a, b)) == b
We also verify that diffing identical records produces an empty diff, and that
applying an empty diff is a no-op.
@@ -14,7 +14,8 @@ import pathlib
import pytest
from jaydiff import jay_diff_full, jay_merge_full
from jaydiff.diff import diff_full
from jaydiff.merge import merge_full
DATA_DIR = pathlib.Path(__file__).parent / "data"
@@ -39,8 +40,8 @@ class TestRealDataRoundTrip:
@pytest.mark.parametrize("pair_index", range(len(CONSECUTIVE_PAIRS)))
def test_consecutive_round_trip(self, pair_index: int) -> None:
a, b = CONSECUTIVE_PAIRS[pair_index]
diff = jay_diff_full(a, b)
result = jay_merge_full(a, diff)
diff = diff_full(a, b)
result = merge_full(a, diff)
assert result == b, (
f"Round-trip failed for pair #{pair_index} "
f"(ts: {a.get('ts')}{b.get('ts')})"
@@ -49,12 +50,12 @@ class TestRealDataRoundTrip:
@pytest.mark.parametrize("pair_index", range(len(CONSECUTIVE_PAIRS)))
def test_diff_of_identical_is_empty(self, pair_index: int) -> None:
a, _ = CONSECUTIVE_PAIRS[pair_index]
assert jay_diff_full(a, a) == {}
assert diff_full(a, a) == {}
@pytest.mark.parametrize("pair_index", range(len(CONSECUTIVE_PAIRS)))
def test_empty_diff_is_noop(self, pair_index: int) -> None:
a, _ = CONSECUTIVE_PAIRS[pair_index]
assert jay_merge_full(a, {}) == a
assert merge_full(a, {}) == a
class TestRealDataProperties:
@@ -80,7 +81,7 @@ class TestRealDataProperties:
"""Diffs between similar consecutive records should usually be smaller."""
savings = 0
for a, b in CONSECUTIVE_PAIRS:
diff = jay_diff_full(a, b)
diff = diff_full(a, b)
if diff: # skip identical pairs
diff_len = len(json.dumps(diff))
full_len = len(json.dumps(b))
-531
View File
@@ -1,531 +0,0 @@
"""Tests for diff."""
import pytest
from copy import deepcopy
from jaydiff.diff import diff_full, diff_add, diff_del, diff_upd, diff_upd_add
from jaydiff.merge import merge_add, merge_delete, merge_update, merge_full
# ── diff_add ──────────────────────────────────────────────────────────────
class TestJayDiffAdd:
def test_add_to_empty(self):
assert diff_add({}, {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}
def test_no_new_keys(self):
assert diff_add({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}) == {}
def test_old_has_extra_keys(self):
assert diff_add({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi', 'd': 'icecream'},
{'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}) == {}
def test_one_new_key(self):
assert diff_add({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}) == {'d': 'icecream'}
def test_new_key_among_overlap(self):
assert diff_add({'a': 'hamburger', 'c': 'pepsi', 'd': 'icecream'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}) == {'b': 'fries'}
def test_nested_new_key(self):
assert diff_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke'}},
{'c': {'f': 'coffee'}}) == {'c': {'f': 'coffee'}}
def test_nested_no_new_keys(self):
assert diff_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}}) == {}
# ── diff_del ──────────────────────────────────────────────────────────────
class TestJayDiffDel:
def test_one_deleted_key(self):
assert diff_del({'a': 'hamburger', 'c': 'pepsi', 'd': 'icecream'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}) == {'d': 'icecream'}
def test_no_deleted_keys_same_values(self):
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': 'coke'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}) == {}
def test_no_deleted_keys_different_values(self):
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': 'coke'},
{'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}) == {}
def test_key_absent_from_new(self):
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'b': 'fries'}) == {'c': 'pepsi'}
def test_nested_deleted_key(self):
# 'c' is absent from _new → None signals "delete entire key"
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries'}) == {'c': None}
def test_nested_partial_delete(self):
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'e': 'coke'}}) == {'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}}
def test_nested_no_delete_different_value(self):
assert diff_del({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'e': 'pepsi', 'f': 'coffee'}}) == {}
# ── diff_upd ──────────────────────────────────────────────────────────────
class TestJayDiffUpd:
def test_multiple_updates(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'potatoes'}) == {'a': 'hotdog', 'b': 'potatoes'}
def test_nested_update(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'e': 'pepsi'}}) == {'c': {'e': 'pepsi'}}
def test_one_update(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'fries'}) == {'a': 'hotdog'}
def test_no_update_new_key_ignored(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}) == {'c': 'coke'}
def test_nested_no_update_new_subkey_ignored(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke'}},
{'c': {'f': 'coffee'}}) == {}
def test_nested_update_one_of_two(self):
assert diff_upd({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'e': 'pepsi', 'f': 'coffee'}}) \
== {'c': {'e': 'pepsi'}}
# ── diff_upd_add ──────────────────────────────────────────────────────────
class TestJayDiffUpdAdd:
def test_update_and_add(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'potatoes', 'd': 'icecream'}) \
== {'a': 'hotdog', 'b': 'potatoes', 'd': 'icecream'}
def test_nested_update_and_add(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'e': 'pepsi', 'g': 'tea', 'h': 'cake'}, 'b': 'potatoes'}) \
== {'b': 'potatoes', 'c': {'e': 'pepsi', 'g': 'tea', 'h': 'cake'}}
def test_update_only(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'fries'}) == {'a': 'hotdog'}
def test_nested_update_and_new_subkey(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'e': 'pepsi', 'f': 'coffee', 'h': 'cake'}}) \
== {'c': {'e': 'pepsi', 'h': 'cake'}}
def test_add_new_top_and_update(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}) \
== {'c': 'coke', 'd': 'icecream'}
def test_identical(self):
assert diff_upd_add({'a': 'hamburger', 'b': 'fries'},
{'a': 'hamburger', 'b': 'fries'}) == {}
# ── diff_full (combine_upd_add=False) ─────────────────────────────────────
class TestJayDiffFullSeparate:
F = False
def test_add_all(self):
assert diff_full({}, {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}, self.F) \
== {'add': {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}}
def test_update_one(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, self.F) \
== {'update': {'c': 'coke'}}
def test_delete_one(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi', 'd': 'icecream'},
{'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}, self.F) \
== {'delete': {'d': 'icecream'}}
def test_add_delete_update(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}, self.F) \
== {'add': {'d': 'icecream'}, 'delete': {'b': 'fries'}, 'update': {'c': 'coke'}}
def test_identical(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'coke'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, self.F) == {}
def test_delete_nested(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries'}, self.F) \
== {'delete': {'c': None}}
def test_nested_partial_delete(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}}, self.F) \
== {'delete': {'c': {'e': 'coke'}}}
def test_update_and_delete(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'potatoes'}, self.F) \
== {'delete': {'c': None}, 'update': {'a': 'hotdog', 'b': 'potatoes'}}
def test_nested_add_delete_update(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'potatoes', 'd': 'icecream'}, self.F) \
== {'add': {'d': 'icecream'}, 'delete': {'c': None}, 'update': {'a': 'hotdog', 'b': 'potatoes'}}
# ── diff_full (combine_upd_add=True) ──────────────────────────────────────
class TestJayDiffFullCombined:
T = True
def test_add_all(self):
assert diff_full({}, {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}, self.T) \
== {'update_add': {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}}
def test_update_one(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, self.T) \
== {'update_add': {'c': 'coke'}}
def test_delete_one(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi', 'd': 'icecream'},
{'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}, self.T) \
== {'delete': {'d': 'icecream'}}
def test_delete_and_update_add(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}, self.T) \
== {'delete': {'b': 'fries'}, 'update_add': {'c': 'coke', 'd': 'icecream'}}
def test_identical(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': 'coke'},
{'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, self.T) == {}
def test_delete_nested(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries'}, self.T) \
== {'delete': {'c': None}}
def test_update_and_delete_nested(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hotdog', 'b': 'potatoes'}, self.T) \
== {'delete': {'c': None}, 'update_add': {'a': 'hotdog', 'b': 'potatoes'}}
def test_nested_update_add_and_delete(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'e': 'pepsi', 'g': 'tea', 'h': 'cake'}, 'b': 'potatoes'}, self.T) \
== {'delete': {'a': 'hamburger', 'c': {'f': 'coffee'}},
'update_add': {'b': 'potatoes', 'c': {'e': 'pepsi', 'g': 'tea', 'h': 'cake'}}}
def test_nested_add_subkey(self):
assert diff_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'a': 'hamburger', 'b': 'fries', 'c': {'e': 'pepsi', 'f': 'coffee', 'h': 'cake'}}, self.T) \
== {'update_add': {'c': {'e': 'pepsi', 'h': 'cake'}}}
# ── merge_* ───────────────────────────────────────────────────────────────
class TestJayMerge:
def test_merge_add_to_empty(self):
assert merge_add({}, {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}
def test_merge_add_new_key(self):
assert merge_add({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'d': 'icecream'}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}, 'd': 'icecream'}
def test_merge_add_existing_unchanged(self):
assert merge_add({'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}, 'd': 'icecream'},
{'d': 'icecream'}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}, 'd': 'icecream'}
def test_merge_delete_top_key(self):
assert merge_delete({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}, 'd': 'icecream'},
{'d': 'icecream'}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}
def test_merge_delete_nested_key(self):
assert merge_delete({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'f': 'coffee'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke'}}
def test_merge_delete_nested_leaves_sibling(self):
assert merge_delete({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'c': {'e': 'coke'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}}
class TestJayMergeFull:
def test_passthrough_when_old_empty(self):
assert merge_full({}, {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}}
def test_empty_diff_unchanged(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, {}) \
== {'a': 'hamburger', 'b': 'fries', 'c': 'coke'}
def test_update(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': 'coke'}, {'update': {'c': 'pepsi'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}
def test_update_add(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}, {'update_add': {'c': 'coke'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': 'coke'}
def test_delete(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi', 'd': 'icecream'},
{'delete': {'d': 'icecream'}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'}
def test_add_delete_update_combined(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': 'pepsi'},
{'add': {'d': 'icecream'}, 'delete': {'b': 'fries'}, 'update': {'c': 'coke'}}) \
== {'a': 'hamburger', 'd': 'icecream', 'c': 'coke'}
def test_nested_update_and_delete(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'delete': {'a': 'hamburger', 'b': 'fries', 'c': {'f': 'coffee'}},
'update': {'c': {'e': 'pepsi'}}}) \
== {'c': {'e': 'pepsi'}}
def test_update_add_new_subkey(self):
assert merge_full({'a': 'hamburger', 'b': 'fries', 'c': {'e': 'coke', 'f': 'coffee'}},
{'update_add': {'c': {'e': 'pepsi', 'h': 'cake'}}}) \
== {'a': 'hamburger', 'b': 'fries', 'c': {'e': 'pepsi', 'f': 'coffee', 'h': 'cake'}}
def test_real_world_snapshot_update(self):
old = {'charging': {
'batteryStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:34Z', 'cruisingRangeElectric_km': 192}},
'chargingStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:34Z'}},
'chargingSettings': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:30Z'}},
'plugStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:29Z'}}}}
update = {'charging': {
'batteryStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:35:46Z', 'cruisingRangeElectric_km': 191}},
'chargingStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:35:46Z'}}}}
add = {'charging': {'batteryStatus': {'value': {'currentSOC_pct': 55}}},
'fuelStatus': {'rangeStatus': {'value': {'primaryEngine': {'currentSOC_pct': 55}}}},
'measurements': {'fuelLevelStatus': {'value': {'currentSOC_pct': 55}}}}
delete = {'charging': {
'chargingSettings': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:30Z'}},
'plugStatus': {'value': {'carCapturedTimestamp': '2024-10-19T05:34:29Z'}}}}
result = merge_full(old, {'update': update, 'add': add, 'delete': delete})
assert result == old
# ── Corner cases: data types ───────────────────────────────────────────────────
class TestNumericAndScalarTypes:
def test_diff_int_value_changed(self):
assert diff_upd({'a': 1, 'b': 2}, {'a': 1, 'b': 3}) == {'b': 3}
def test_diff_float_value_changed(self):
assert diff_upd({'a': 1.0}, {'a': 1.1}) == {'a': 1.1}
def test_diff_int_unchanged(self):
assert diff_upd({'a': 42}, {'a': 42}) == {}
def test_diff_bool_changed(self):
assert diff_upd({'a': True}, {'a': False}) == {'a': False}
def test_diff_bool_false_to_true(self):
assert diff_upd({'online': False}, {'online': True}) == {'online': True}
def test_diff_none_value_changed(self):
assert diff_upd({'a': None}, {'a': 'something'}) == {'a': 'something'}
def test_diff_value_to_none(self):
assert diff_upd({'a': 'something'}, {'a': None}) == {'a': None}
def test_diff_list_unchanged(self):
assert diff_upd({'a': [1, 2, 3]}, {'a': [1, 2, 3]}) == {}
def test_diff_list_changed(self):
assert diff_upd({'a': [1, 2]}, {'a': [1, 2, 3]}) == {'a': [1, 2, 3]}
def test_add_numeric_key(self):
assert diff_add({'a': 1}, {'a': 1, 'b': 2}) == {'b': 2}
def test_full_combined_numeric(self):
old = {'soc': 80, 'range': 300, 'temp': 22.5}
new = {'soc': 75, 'range': 280, 'temp': 22.5}
diff = diff_full(old, new)
assert diff == {'update_add': {'soc': 75, 'range': 280}}
def test_merge_numeric_update(self):
assert merge_full({'soc': 80, 'range': 300}, {'update_add': {'soc': 75}}) \
== {'soc': 75, 'range': 300}
# ── Corner cases: type changes (dict ↔ scalar) ────────────────────────────────
class TestTypeChanges:
def test_dict_replaced_by_scalar_in_diff(self):
# A key that was a dict becomes a scalar — upd treats the whole value as changed
old = {'a': {'b': 'c'}}
new = {'a': 'flat'}
diff = diff_upd(old, new)
assert diff == {'a': 'flat'}
def test_merge_dict_to_scalar(self):
old = {'a': {'b': 'c'}, 'x': 1}
diff = {'update_add': {'a': 'flat'}}
result = merge_full(old, diff)
assert result == {'a': 'flat', 'x': 1}
def test_diff_full_dict_to_scalar_combined(self):
old = {'status': {'value': 42}}
new = {'status': 'unknown'}
diff = diff_full(old, new)
result = merge_full(deepcopy(old), diff)
assert result == new
def test_diff_add_new_nested_structure(self):
# Key absent in old, present as dict in new — treated as addition
old = {'a': 1}
new = {'a': 1, 'b': {'c': 2}}
diff = diff_add(old, new)
assert diff == {'b': {'c': 2}}
# ── Corner cases: completely different structures ─────────────────────────────
class TestCompletelyDifferentStructure:
def test_all_keys_replaced(self):
old = {'a': 1, 'b': 2}
new = {'x': 10, 'y': 20}
diff = diff_full(old, new)
assert diff == {'update_add': {'x': 10, 'y': 20}, 'delete': {'a': 1, 'b': 2}}
def test_all_keys_replaced_deep(self):
old = {'charging': {'state': 'charging', 'power': 11}}
new = {'electric_drive': {'range': 300, 'soc': 80}}
diff = diff_full(old, new)
assert diff == {
'update_add': {'electric_drive': {'range': 300, 'soc': 80}},
'delete': {'charging': None},
}
def test_merge_round_trip_all_keys_replaced(self):
old = {'a': 1, 'b': 2}
new = {'x': 10, 'y': 20}
diff = diff_full(old, new)
result = merge_full(deepcopy(old), diff)
assert result == new
def test_diff_disjoint_no_update(self):
# No shared keys → no updates, only add/delete
old = {'a': 1}
new = {'b': 2}
diff = diff_upd(old, new)
assert diff == {}
def test_diff_disjoint_add(self):
assert diff_add({'a': 1}, {'b': 2}) == {'b': 2}
def test_diff_disjoint_del(self):
assert diff_del({'a': 1}, {'b': 2}) == {'a': 1}
# ── Corner cases: empty containers ────────────────────────────────────────────
class TestEmptyContainers:
def test_diff_both_empty(self):
assert diff_full({}, {}) == {}
def test_diff_old_empty_new_has_data(self):
diff = diff_full({}, {'a': 1})
assert diff == {'update_add': {'a': 1}}
def test_diff_new_empty_old_has_data(self):
diff = diff_full({'a': 1}, {})
assert diff == {'delete': {'a': 1}}
def test_merge_delete_all_keys(self):
result = merge_full({'a': 1, 'b': 2}, {'delete': {'a': 1, 'b': 2}})
assert result == {}
def test_delete_empty_dict_value(self):
# Deleting a key whose value is {} — should not recurse (fixed bug)
result = merge_delete({'a': 'keep', 'b': {}}, {'b': {}})
assert result == {'a': 'keep'}
def test_diff_nested_empty_dict(self):
old = {'a': {'b': 'c'}}
new = {'a': {}}
diff = 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 = diff_full(old, new)
result = merge_full(deepcopy(old), diff)
assert result == new # 'x' must remain as {}
# ── Corner cases: deep nesting ────────────────────────────────────────────────
class TestDeepNesting:
def test_diff_three_levels(self):
old = {'l1': {'l2': {'l3': 'old'}}}
new = {'l1': {'l2': {'l3': 'new'}}}
assert diff_upd(old, new) == {'l1': {'l2': {'l3': 'new'}}}
def test_merge_three_levels(self):
old = {'l1': {'l2': {'l3': 'old', 'keep': 'yes'}}}
diff = {'update_add': {'l1': {'l2': {'l3': 'new'}}}}
result = merge_full(deepcopy(old), diff)
assert result == {'l1': {'l2': {'l3': 'new', 'keep': 'yes'}}}
def test_diff_full_three_levels(self):
old = {'a': {'b': {'c': {'d': 'deep'}}}}
new = {'a': {'b': {'c': {'d': 'deeper'}}}}
diff = diff_full(old, new)
result = merge_full(deepcopy(old), diff)
assert result == new
# ── Round-trip: merge(old, diff(old, new)) == new ────────────────────────────
class TestRoundTrip:
"""Verify that applying a diff to old always produces new."""
@pytest.mark.parametrize("old,new", [
({'a': 1}, {'a': 2}),
({'a': 1, 'b': 2}, {'b': 3, 'c': 4}),
({'x': {'y': 'old'}}, {'x': {'y': 'new', 'z': 'added'}}),
({'a': {'b': 1}, 'c': 2}, {'a': {'b': 1}, 'd': 3}),
({'soc': 80, 'range': 300}, {'soc': 60, 'range': 240, 'temp': 25}),
({'a': True, 'b': None}, {'a': False, 'c': 42}),
({'l1': {'l2': {'v': 1}}}, {'l1': {'l2': {'v': 2}}}),
({}, {'a': 1}),
({'a': 1}, {}),
])
def test_round_trip_combined(self, old, new):
diff = diff_full(deepcopy(old), deepcopy(new), combine_upd_add=True)
result = merge_full(deepcopy(old), diff)
assert result == new
@pytest.mark.parametrize("old,new", [
({'a': 1}, {'a': 2}),
({'a': 1, 'b': 2}, {'b': 3, 'c': 4}),
({'x': {'y': 'old'}}, {'x': {'y': 'new', 'z': 'added'}}),
({}, {'a': 1}),
({'a': 1}, {}),
])
def test_round_trip_separate(self, old, new):
diff = diff_full(deepcopy(old), deepcopy(new), combine_upd_add=False)
result = merge_full(deepcopy(old), diff)
assert result == new