Skip to content

Commit 9d44eaa

Browse files
committed
feat(config): add support for detecting component level changes
1 parent 544347f commit 9d44eaa

File tree

2 files changed

+417
-0
lines changed

2 files changed

+417
-0
lines changed

tests/test_config.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
import pytest
88

99
from viseron.config import (
10+
ComponentChange,
11+
ConfigDiff,
1012
DomainChange,
1113
IdentifierChange,
1214
create_default_config,
15+
diff_config,
1316
diff_domain_config,
1417
diff_identifier_config,
1518
load_config,
@@ -715,3 +718,276 @@ def test_unchanged_domains_not_included(self) -> None:
715718
result = diff_domain_config("darknet", old, new)
716719
assert len(result) == 1
717720
assert result[0].domain == "object_detector"
721+
722+
723+
class TestComponentChange:
724+
"""Test ComponentChange dataclass."""
725+
726+
def test_component_added(self) -> None:
727+
"""Test is_added/is_removed/is_component_level_change for added component."""
728+
change = ComponentChange(
729+
component_name="ffmpeg",
730+
old_config=None,
731+
new_config={"camera": {"cam1": {"host": "192.168.1.1"}}},
732+
)
733+
assert change.is_added is True
734+
assert change.is_removed is False
735+
assert change.is_component_level_change is True
736+
737+
def test_component_removed(self) -> None:
738+
"""Test is_added/is_removed/is_component_level_change for removed component."""
739+
change = ComponentChange(
740+
component_name="ffmpeg",
741+
old_config={"camera": {"cam1": {"host": "192.168.1.1"}}},
742+
new_config=None,
743+
)
744+
assert change.is_added is False
745+
assert change.is_removed is True
746+
assert change.is_component_level_change is True
747+
748+
def test_component_both_none(self) -> None:
749+
"""Test all properties return False when both configs are None."""
750+
change = ComponentChange(
751+
component_name="ffmpeg",
752+
old_config=None,
753+
new_config=None,
754+
)
755+
assert change.is_added is False
756+
assert change.is_removed is False
757+
assert change.is_component_level_change is False
758+
759+
def test_is_component_level_change_only_domain_keys_differ(self) -> None:
760+
"""Test component-level change is False when only domain keys differ."""
761+
change = ComponentChange(
762+
component_name="darknet",
763+
old_config={
764+
"object_detector": {"threshold": 0.5, "cameras": {"cam1": {}}},
765+
},
766+
new_config={
767+
"object_detector": {"threshold": 0.9, "cameras": {"cam1": {}}},
768+
},
769+
)
770+
assert change.is_component_level_change is False
771+
772+
def test_is_component_level_change_non_domain_key_differs(self) -> None:
773+
"""Test component-level change is True when non-domain config differs."""
774+
change = ComponentChange(
775+
component_name="darknet",
776+
old_config={
777+
"log_level": "info",
778+
"object_detector": {"cameras": {"cam1": {}}},
779+
},
780+
new_config={
781+
"log_level": "debug",
782+
"object_detector": {"cameras": {"cam1": {}}},
783+
},
784+
)
785+
assert change.is_component_level_change is True
786+
787+
def test_is_component_level_change_no_change(self) -> None:
788+
"""Test component-level change is False when configs are identical."""
789+
config: dict[str, Any] = {
790+
"log_level": "info",
791+
"camera": {"cam1": {"host": "192.168.1.1"}},
792+
}
793+
change = ComponentChange(
794+
component_name="ffmpeg",
795+
old_config=config.copy(),
796+
new_config=config.copy(),
797+
)
798+
assert change.is_component_level_change is False
799+
800+
def test_get_domain_configs(self) -> None:
801+
"""Test get_domain_configs extracts only domain keys from configs."""
802+
change = ComponentChange(
803+
component_name="darknet",
804+
old_config={
805+
"log_level": "info",
806+
"object_detector": {"threshold": 0.5},
807+
"camera": {"cam1": {}},
808+
},
809+
new_config={
810+
"log_level": "debug",
811+
"object_detector": {"threshold": 0.9},
812+
"motion_detector": {"area": 100},
813+
},
814+
)
815+
old_domains, new_domains = change.get_domain_configs()
816+
assert old_domains == {
817+
"object_detector": {"threshold": 0.5},
818+
"camera": {"cam1": {}},
819+
}
820+
assert new_domains == {
821+
"object_detector": {"threshold": 0.9},
822+
"motion_detector": {"area": 100},
823+
}
824+
825+
def test_get_domain_configs_no_domains(self) -> None:
826+
"""Test get_domain_configs returns empty dicts when no domain keys exist."""
827+
change = ComponentChange(
828+
component_name="mqtt",
829+
old_config={"broker": "localhost", "port": 1883},
830+
new_config={"broker": "10.0.0.1", "port": 1883},
831+
)
832+
old_domains, new_domains = change.get_domain_configs()
833+
assert not old_domains
834+
assert not new_domains
835+
836+
def test_get_domain_configs_both_none(self) -> None:
837+
"""Test get_domain_configs when both configs are None."""
838+
change = ComponentChange(
839+
component_name="ffmpeg",
840+
old_config=None,
841+
new_config=None,
842+
)
843+
old_domains, new_domains = change.get_domain_configs()
844+
assert not old_domains
845+
assert not new_domains
846+
847+
def test_domain_changes_default_empty(self) -> None:
848+
"""Test that domain_changes defaults to an empty list."""
849+
change = ComponentChange(
850+
component_name="ffmpeg",
851+
old_config={},
852+
new_config={},
853+
)
854+
assert not change.domain_changes
855+
856+
857+
class TestConfigDiff:
858+
"""Test ConfigDiff dataclass."""
859+
860+
def test_has_changes_empty(self) -> None:
861+
"""Test has_changes is False for empty ConfigDiff."""
862+
diff = ConfigDiff()
863+
assert diff.has_changes is False
864+
865+
def test_has_changes_with_entries(self) -> None:
866+
"""Test has_changes is True when component_changes are present."""
867+
diff = ConfigDiff(
868+
component_changes={
869+
"ffmpeg": ComponentChange("ffmpeg", None, {"camera": {}}),
870+
}
871+
)
872+
assert diff.has_changes is True
873+
874+
def test_get_added_removed_modified(self) -> None:
875+
"""Test get_added/removed/modified_components categorization."""
876+
diff = ConfigDiff(
877+
component_changes={
878+
"new_comp": ComponentChange("new_comp", None, {"key": "val"}),
879+
"old_comp": ComponentChange("old_comp", {"key": "val"}, None),
880+
"mod_comp": ComponentChange("mod_comp", {"key": "old"}, {"key": "new"}),
881+
}
882+
)
883+
assert diff.get_added_components() == ["new_comp"]
884+
assert diff.get_removed_components() == ["old_comp"]
885+
assert diff.get_modified_components() == ["mod_comp"]
886+
887+
def test_get_component_change(self) -> None:
888+
"""Test get_component_change returns the correct entry or None."""
889+
change = ComponentChange("ffmpeg", None, {"camera": {}})
890+
diff = ConfigDiff(component_changes={"ffmpeg": change})
891+
assert diff.get_component_change("ffmpeg") is change
892+
assert diff.get_component_change("nonexistent") is None
893+
894+
895+
class TestDiffConfig:
896+
"""Test diff_config function."""
897+
898+
def test_no_changes(self) -> None:
899+
"""Test diff when old and new configs are identical."""
900+
config: dict[str, Any] = {
901+
"ffmpeg": {"camera": {"cam1": {}}},
902+
"mqtt": {"broker": "localhost"},
903+
}
904+
result = diff_config(config, config.copy())
905+
assert not result.has_changes
906+
907+
def test_component_added(self) -> None:
908+
"""Test diff detects a newly added component."""
909+
old: dict[str, Any] = {"ffmpeg": {"camera": {"cam1": {}}}}
910+
new: dict[str, Any] = {
911+
"ffmpeg": {"camera": {"cam1": {}}},
912+
"mqtt": {"broker": "localhost"},
913+
}
914+
result = diff_config(old, new)
915+
assert result.get_added_components() == ["mqtt"]
916+
change = result.get_component_change("mqtt")
917+
assert change is not None
918+
assert change.old_config is None
919+
assert change.new_config == {"broker": "localhost"}
920+
921+
def test_component_removed(self) -> None:
922+
"""Test diff detects a removed component."""
923+
old: dict[str, Any] = {
924+
"ffmpeg": {"camera": {"cam1": {}}},
925+
"mqtt": {"broker": "localhost"},
926+
}
927+
new: dict[str, Any] = {"ffmpeg": {"camera": {"cam1": {}}}}
928+
result = diff_config(old, new)
929+
assert result.get_removed_components() == ["mqtt"]
930+
change = result.get_component_change("mqtt")
931+
assert change is not None
932+
assert change.old_config == {"broker": "localhost"}
933+
assert change.new_config is None
934+
935+
def test_component_modified(self) -> None:
936+
"""Test diff detects a modified component."""
937+
old: dict[str, Any] = {"mqtt": {"broker": "localhost"}}
938+
new: dict[str, Any] = {"mqtt": {"broker": "10.0.0.1"}}
939+
result = diff_config(old, new)
940+
assert result.get_modified_components() == ["mqtt"]
941+
change = result.get_component_change("mqtt")
942+
assert change is not None
943+
assert change.old_config == {"broker": "localhost"}
944+
assert change.new_config == {"broker": "10.0.0.1"}
945+
946+
def test_multiple_changes(self) -> None:
947+
"""Test diff with added, removed, and modified components at once."""
948+
old: dict[str, Any] = {
949+
"ffmpeg": {"camera": {"cam1": {}}},
950+
"mqtt": {"broker": "localhost"},
951+
}
952+
new: dict[str, Any] = {
953+
"ffmpeg": {"camera": {"cam1": {}, "cam2": {}}},
954+
"darknet": {"object_detector": {"threshold": 0.5}},
955+
}
956+
result = diff_config(old, new)
957+
assert "ffmpeg" in result.get_modified_components()
958+
assert "mqtt" in result.get_removed_components()
959+
assert "darknet" in result.get_added_components()
960+
961+
def test_both_empty(self) -> None:
962+
"""Test diff with empty configs."""
963+
result = diff_config({}, {})
964+
assert not result.has_changes
965+
966+
def test_deep_copies_configs(self) -> None:
967+
"""Test that diff_config deep copies config values."""
968+
old: dict[str, Any] = {"ffmpeg": {"camera": {"cam1": {"host": "192.168.1.1"}}}}
969+
new: dict[str, Any] = {"mqtt": {"broker": "localhost"}}
970+
result = diff_config(old, new)
971+
972+
old["ffmpeg"]["camera"]["cam1"]["host"] = "MUTATED"
973+
new["mqtt"]["broker"] = "MUTATED"
974+
ffmpeg = result.get_component_change("ffmpeg")
975+
assert ffmpeg and ffmpeg.old_config == {
976+
"camera": {"cam1": {"host": "192.168.1.1"}}
977+
}
978+
mqtt = result.get_component_change("mqtt")
979+
assert mqtt and mqtt.new_config == {"broker": "localhost"}
980+
981+
def test_unchanged_components_not_included(self) -> None:
982+
"""Test that components with no changes are excluded from the result."""
983+
old: dict[str, Any] = {
984+
"ffmpeg": {"camera": {"cam1": {}}},
985+
"mqtt": {"broker": "localhost"},
986+
}
987+
new: dict[str, Any] = {
988+
"ffmpeg": {"camera": {"cam1": {}}},
989+
"mqtt": {"broker": "10.0.0.1"},
990+
}
991+
result = diff_config(old, new)
992+
assert result.get_component_change("ffmpeg") is None
993+
assert result.get_component_change("mqtt") is not None

0 commit comments

Comments
 (0)