211 lines
8.2 KiB
Python
211 lines
8.2 KiB
Python
# ── 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 |