diff --git a/tests/jaydiff/__init__.py b/tests/jaydiff/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/2026_05_25_17_12_38_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_25_17_12_38_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_25_17_12_38_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_25_17_12_38_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_25_17_28_48_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_25_17_28_48_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_25_17_28_48_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_25_17_28_48_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_25_17_40_44_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_25_17_40_44_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_25_17_40_44_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_25_17_40_44_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_25_17_56_43_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_25_17_56_43_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_25_17_56_43_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_25_17_56_43_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_25_18_17_07_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_25_18_17_07_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_25_18_17_07_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_25_18_17_07_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_25_19_06_22_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_25_19_06_22_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_25_19_06_22_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_25_19_06_22_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_25_19_12_47_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_25_19_12_47_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_25_19_12_47_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_25_19_12_47_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_25_19_17_57_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_25_19_17_57_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_25_19_17_57_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_25_19_17_57_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_25_19_27_12_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_25_19_27_12_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_25_19_27_12_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_25_19_27_12_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_25_19_36_44_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_25_19_36_44_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_25_19_36_44_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_25_19_36_44_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_26_10_41_07_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_26_10_41_07_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_26_10_41_07_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_26_10_41_07_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_26_11_24_21_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_26_11_24_21_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_26_11_24_21_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_26_11_24_21_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_26_11_37_05_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_26_11_37_05_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_26_11_37_05_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_26_11_37_05_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_26_11_52_40_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_26_11_52_40_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_26_11_52_40_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_26_11_52_40_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_26_19_47_34_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_26_19_47_34_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_26_19_47_34_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_26_19_47_34_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_27_05_53_07_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_27_05_53_07_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_27_05_53_07_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_27_05_53_07_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_27_05_55_02_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_27_05_55_02_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_27_05_55_02_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_27_05_55_02_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_27_06_17_18_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_27_06_17_18_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_27_06_17_18_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_27_06_17_18_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_27_06_18_28_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_27_06_18_28_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_27_06_18_28_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_27_06_18_28_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_27_06_21_01_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_27_06_21_01_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_27_06_21_01_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_27_06_21_01_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_27_06_33_08_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_27_06_33_08_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_27_06_33_08_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_27_06_33_08_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_27_06_40_48_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_27_06_40_48_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_27_06_40_48_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_27_06_40_48_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_27_06_54_58_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_27_06_54_58_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_27_06_54_58_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_27_06_54_58_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_27_09_31_50_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_27_09_31_50_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_27_09_31_50_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_27_09_31_50_WVWZZZE1ZMP010760.json diff --git a/tests/data/2026_05_27_09_35_00_WVWZZZE1ZMP010760.json b/tests/jaydiff/data/2026_05_27_09_35_00_WVWZZZE1ZMP010760.json similarity index 100% rename from tests/data/2026_05_27_09_35_00_WVWZZZE1ZMP010760.json rename to tests/jaydiff/data/2026_05_27_09_35_00_WVWZZZE1ZMP010760.json diff --git a/tests/jaydiff/test_corner_cases.py b/tests/jaydiff/test_corner_cases.py new file mode 100644 index 0000000..3502512 --- /dev/null +++ b/tests/jaydiff/test_corner_cases.py @@ -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 \ No newline at end of file diff --git a/tests/jaydiff/test_diff.py b/tests/jaydiff/test_diff.py new file mode 100644 index 0000000..4549ae0 --- /dev/null +++ b/tests/jaydiff/test_diff.py @@ -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'}}} + + diff --git a/tests/jaydiff/test_merge.py b/tests/jaydiff/test_merge.py new file mode 100644 index 0000000..91c8aa8 --- /dev/null +++ b/tests/jaydiff/test_merge.py @@ -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 + + diff --git a/tests/test_jay_diff_real_data.py b/tests/jaydiff/test_real_data.py similarity index 91% rename from tests/test_jay_diff_real_data.py rename to tests/jaydiff/test_real_data.py index f95e75a..5defa99 100644 --- a/tests/test_jay_diff_real_data.py +++ b/tests/jaydiff/test_real_data.py @@ -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)) diff --git a/tests/test_jay_diff.py b/tests/test_jay_diff.py deleted file mode 100644 index aba7ed2..0000000 --- a/tests/test_jay_diff.py +++ /dev/null @@ -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 \ No newline at end of file