Skip to content

Commit 2f5bff7

Browse files
committed
feat(core): track setup status of components on start/reload
1 parent bc91670 commit 2f5bff7

7 files changed

Lines changed: 576 additions & 52 deletions

File tree

tests/components/test__init__.py

Lines changed: 252 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from viseron.components import (
1313
Component,
14+
ComponentErrorSource,
15+
ComponentState,
1416
CriticalComponentsConfigStore,
1517
activate_safe_mode,
1618
setup_component,
@@ -221,7 +223,8 @@ def test_validate_config_no_schema(self, vis: MockViseron) -> None:
221223
):
222224
result = component.validate_component_config()
223225

224-
assert result is True
226+
assert result.success is True
227+
assert result.error is None
225228

226229
def test_validate_config_valid_schema(self, vis: MockViseron) -> None:
227230
"""Test validation with valid CONFIG_SCHEMA."""
@@ -238,7 +241,9 @@ def schema(_config: dict[str, bool]) -> dict[str, bool]:
238241
):
239242
result = component.validate_component_config()
240243

241-
assert result == validated_config
244+
assert result.success is True
245+
assert result.config == validated_config
246+
assert result.error is None
242247

243248
def test_validate_config_vol_invalid(
244249
self, vis: MockViseron, caplog: pytest.LogCaptureFixture
@@ -254,7 +259,9 @@ def test_validate_config_vol_invalid(
254259
):
255260
result = component.validate_component_config()
256261

257-
assert result is None
262+
assert result.success is False
263+
assert result.error is not None
264+
assert "Invalid config value" in result.error
258265
assert "Error validating config for component test_comp" in caplog.text
259266

260267
def test_validate_config_generic_exception(
@@ -271,7 +278,9 @@ def test_validate_config_generic_exception(
271278
):
272279
result = component.validate_component_config()
273280

274-
assert result is None
281+
assert result.success is False
282+
assert result.error is not None
283+
assert "Unexpected error" in result.error
275284
assert "Unknown error calling test_comp CONFIG_SCHEMA" in caplog.text
276285

277286

@@ -291,7 +300,7 @@ def test_setup_component_success(
291300
"viseron.components.importlib.import_module", return_value=mock_module
292301
):
293302
component = Component(vis, "viseron.components.test", "test", {})
294-
result: bool = component.setup_component()
303+
result = component.setup_component()
295304

296305
assert result is True
297306
assert "Setting up component test" in caplog.text
@@ -309,7 +318,7 @@ def test_setup_component_failure(
309318
"viseron.components.importlib.import_module", return_value=mock_module
310319
):
311320
component = Component(vis, "viseron.components.test", "test", {})
312-
result: bool = component.setup_component()
321+
result = component.setup_component()
313322

314323
assert result is False
315324
assert "Setup of component test failed" in caplog.text
@@ -326,7 +335,7 @@ def test_setup_component_non_boolean_return(
326335
"viseron.components.importlib.import_module", return_value=mock_module
327336
):
328337
component = Component(vis, "viseron.components.test", "test", {})
329-
result: bool = component.setup_component()
338+
result = component.setup_component()
330339

331340
assert result is False
332341
assert "Setup of component test did not return boolean" in caplog.text
@@ -348,10 +357,10 @@ def test_setup_component_not_ready_creates_retry_timer(
348357
patch("viseron.components.NamedTimer") as mock_named_timer,
349358
):
350359
component = Component(vis, "viseron.components.test", "test", {})
351-
result: bool = component.setup_component(tries=1)
360+
result = component.setup_component(tries=1)
352361

353-
# Setup returns False but schedules a retry
354-
assert result is False
362+
# Setup returns None (retry in progress)
363+
assert result is None
355364
assert "Component test is not ready" in caplog.text
356365
assert "Retrying in" in caplog.text
357366

@@ -377,7 +386,7 @@ def test_setup_component_not_ready_shutdown_aborts(
377386
"viseron.components.importlib.import_module", return_value=mock_module
378387
):
379388
component = Component(vis, "viseron.components.test", "test", {})
380-
result: bool = component.setup_component()
389+
result = component.setup_component()
381390

382391
assert result is False
383392
assert "setup aborted due to shutdown" in caplog.text
@@ -396,7 +405,7 @@ def test_setup_component_uncaught_exception(
396405
"viseron.components.importlib.import_module", return_value=mock_module
397406
):
398407
component = Component(vis, "viseron.components.test", "test", {})
399-
result: bool = component.setup_component()
408+
result = component.setup_component()
400409

401410
assert result is False
402411
assert "Uncaught exception setting up component test" in caplog.text
@@ -422,7 +431,7 @@ def test_setup_component_clears_pending_domains_on_failure(
422431
"viseron.components.importlib.import_module", return_value=mock_module
423432
):
424433
component = Component(vis, "viseron.components.test", "test", {})
425-
result: bool = component.setup_component()
434+
result = component.setup_component()
426435

427436
assert result is False
428437
# Domain should be unregistered
@@ -646,3 +655,233 @@ def test_component_loading_to_failed(self, vis: MockViseron) -> None:
646655
assert "test" not in vis.data[LOADED]
647656
assert "test" not in vis.data[LOADING]
648657
assert "test" in vis.data[FAILED]
658+
659+
660+
class TestComponentState:
661+
"""Test Component state and error tracking."""
662+
663+
def test_initial_state_is_loading(self, vis: MockViseron) -> None:
664+
"""Test component starts in LOADING state."""
665+
component = Component(vis, "viseron.components.test", "test", {})
666+
assert component.state == ComponentState.LOADING
667+
668+
def test_state_loaded_on_success(self, vis: MockViseron) -> None:
669+
"""Test component state set to LOADED on successful setup."""
670+
mock_module = MockComponentModule(setup_return=True)
671+
with patch(
672+
"viseron.components.importlib.import_module", return_value=mock_module
673+
):
674+
component = Component(vis, "viseron.components.test", "test", {})
675+
result = component.setup_component()
676+
assert result is True
677+
assert component.state == ComponentState.LOADED
678+
679+
def test_state_failed_on_failure(self, vis: MockViseron) -> None:
680+
"""Test component state set to FAILED on setup failure."""
681+
mock_module = MockComponentModule(setup_return=False)
682+
with patch(
683+
"viseron.components.importlib.import_module", return_value=mock_module
684+
):
685+
component = Component(vis, "viseron.components.test", "test", {})
686+
result = component.setup_component()
687+
assert result is False
688+
assert component.state == ComponentState.FAILED
689+
690+
def test_state_retrying_on_not_ready(self, vis: MockViseron) -> None:
691+
"""Test component state set to RETRYING on ComponentNotReady."""
692+
mock_module = MockComponentModule(
693+
setup_exception=ComponentNotReady("Not ready")
694+
)
695+
with (
696+
patch(
697+
"viseron.components.importlib.import_module", return_value=mock_module
698+
),
699+
patch("viseron.components.NamedTimer"),
700+
):
701+
component = Component(vis, "viseron.components.test", "test", {})
702+
component.setup_component()
703+
assert component.state == ComponentState.RETRYING
704+
705+
def test_state_failed_on_shutdown_abort(self, vis: MockViseron) -> None:
706+
"""Test component state set to FAILED when shutdown aborts setup."""
707+
mock_module = MockComponentModule(
708+
setup_exception=ComponentNotReady("Not ready")
709+
)
710+
vis.shutdown_event.set()
711+
with patch(
712+
"viseron.components.importlib.import_module", return_value=mock_module
713+
):
714+
component = Component(vis, "viseron.components.test", "test", {})
715+
component.setup_component()
716+
assert component.state == ComponentState.FAILED
717+
718+
def test_setup_component_sets_state_loading(self, vis: MockViseron) -> None:
719+
"""Test setup_component() sets state to LOADING at start."""
720+
mock_module = MockComponentModule(setup_return=True)
721+
with patch(
722+
"viseron.components.importlib.import_module", return_value=mock_module
723+
):
724+
component = Component(vis, "viseron.components.test", "test", {})
725+
setup_component(vis, component)
726+
# After successful setup, state should be LOADED
727+
assert component.state == ComponentState.LOADED
728+
729+
def test_setup_component_sets_state_failed_on_module_not_found(
730+
self, vis: MockViseron
731+
) -> None:
732+
"""Test setup_component() sets FAILED on ModuleNotFoundError."""
733+
mock_component = MockComponent(
734+
vis,
735+
"testing",
736+
setup_component=Mock(side_effect=ModuleNotFoundError("testing")),
737+
)
738+
with patch("viseron.components.Component", new=mock_component):
739+
setup_component(vis, mock_component)
740+
assert mock_component.state == ComponentState.FAILED
741+
742+
743+
class TestComponentErrors:
744+
"""Test Component error accumulation."""
745+
746+
def test_add_error(self, vis: MockViseron) -> None:
747+
"""Test adding an error to a component."""
748+
component = Component(vis, "viseron.components.test", "test", {})
749+
component.add_error(ComponentErrorSource.SETUP, "Something went wrong")
750+
errors = component.errors
751+
assert len(errors) == 1
752+
assert errors[0].source == ComponentErrorSource.SETUP
753+
assert errors[0].message == "Something went wrong"
754+
assert errors[0].component_name == "test"
755+
assert errors[0].domain is None
756+
assert errors[0].identifier is None
757+
758+
def test_add_error_with_domain(self, vis: MockViseron) -> None:
759+
"""Test adding an error with domain context."""
760+
component = Component(vis, "viseron.components.test", "test", {})
761+
component.add_error(
762+
ComponentErrorSource.DOMAIN,
763+
"Camera failed",
764+
domain="camera",
765+
identifier="cam1",
766+
)
767+
errors = component.errors
768+
assert len(errors) == 1
769+
assert errors[0].source == ComponentErrorSource.DOMAIN
770+
assert errors[0].domain == "camera"
771+
assert errors[0].identifier == "cam1"
772+
773+
def test_clear_errors(self, vis: MockViseron) -> None:
774+
"""Test clearing errors."""
775+
component = Component(vis, "viseron.components.test", "test", {})
776+
component.add_error(ComponentErrorSource.SETUP, "Error 1")
777+
component.add_error(ComponentErrorSource.SETUP, "Error 2")
778+
assert len(component.errors) == 2
779+
component.clear_errors()
780+
assert len(component.errors) == 0
781+
782+
def test_errors_returns_copy(self, vis: MockViseron) -> None:
783+
"""Test that errors property returns a copy."""
784+
component = Component(vis, "viseron.components.test", "test", {})
785+
component.add_error(ComponentErrorSource.SETUP, "Error")
786+
errors1 = component.errors
787+
errors2 = component.errors
788+
assert errors1 is not errors2
789+
assert errors1 == errors2
790+
791+
def test_validation_error_recorded(self, vis: MockViseron) -> None:
792+
"""Test validation failure records error on component."""
793+
mock_module = MockComponentModule(
794+
config_schema=Mock(side_effect=vol.Invalid("Bad config"))
795+
)
796+
with patch(
797+
"viseron.components.importlib.import_module", return_value=mock_module
798+
):
799+
component = Component(vis, "test_path", "test_comp", {"key": "value"})
800+
component.validate_component_config()
801+
802+
errors = component.errors
803+
assert len(errors) == 1
804+
assert errors[0].source == ComponentErrorSource.VALIDATION
805+
assert "Bad config" in errors[0].message
806+
807+
def test_setup_exception_error_recorded(self, vis: MockViseron) -> None:
808+
"""Test uncaught exception during setup records error."""
809+
mock_module = MockComponentModule(
810+
setup_exception=RuntimeError("Unexpected crash")
811+
)
812+
with patch(
813+
"viseron.components.importlib.import_module", return_value=mock_module
814+
):
815+
component = Component(vis, "viseron.components.test", "test", {})
816+
component.setup_component()
817+
818+
errors = component.errors
819+
assert len(errors) == 1
820+
assert errors[0].source == ComponentErrorSource.SETUP
821+
assert "Unexpected crash" in errors[0].message
822+
823+
def test_not_ready_error_recorded(self, vis: MockViseron) -> None:
824+
"""Test ComponentNotReady records error."""
825+
mock_module = MockComponentModule(
826+
setup_exception=ComponentNotReady("Not ready yet")
827+
)
828+
with (
829+
patch(
830+
"viseron.components.importlib.import_module", return_value=mock_module
831+
),
832+
patch("viseron.components.NamedTimer"),
833+
):
834+
component = Component(vis, "viseron.components.test", "test", {})
835+
component.setup_component()
836+
837+
errors = component.errors
838+
assert len(errors) == 1
839+
assert errors[0].source == ComponentErrorSource.SETUP
840+
assert "Not ready yet" in errors[0].message
841+
842+
def test_module_not_found_error_recorded(self, vis: MockViseron) -> None:
843+
"""Test ModuleNotFoundError records error on component."""
844+
mock_component = MockComponent(
845+
vis,
846+
"testing",
847+
setup_component=Mock(side_effect=ModuleNotFoundError("no module")),
848+
)
849+
with patch("viseron.components.Component", new=mock_component):
850+
setup_component(vis, mock_component)
851+
852+
errors = mock_component.errors
853+
assert len(errors) == 1
854+
assert errors[0].source == ComponentErrorSource.IMPORT
855+
assert "no module" in errors[0].message
856+
857+
def test_retry_clears_errors(self, vis: MockViseron) -> None:
858+
"""Test that retrying (tries > 1) clears previous errors."""
859+
mock_module = MockComponentModule(setup_return=True)
860+
component = Component(vis, "viseron.components.test", "test", {})
861+
component.add_error(ComponentErrorSource.SETUP, "Previous error")
862+
vis.data[FAILED]["test"] = component
863+
864+
with patch(
865+
"viseron.components.importlib.import_module", return_value=mock_module
866+
):
867+
setup_component(vis, component, tries=2)
868+
869+
assert len(component.errors) == 0
870+
assert component.state == ComponentState.LOADED
871+
872+
873+
class TestComponentStatusDict:
874+
"""Test Component.as_status_dict() method."""
875+
876+
def test_as_status_dict(self, vis: MockViseron) -> None:
877+
"""Test as_status_dict returns correct structure."""
878+
component = Component(vis, "viseron.components.test", "test", {})
879+
component.state = ComponentState.LOADED
880+
component.add_error(ComponentErrorSource.SETUP, "A warning")
881+
882+
status = component.as_status_dict()
883+
assert status["name"] == "test"
884+
assert status["state"] == "loaded"
885+
assert len(status["errors"]) == 1
886+
assert status["errors"][0]["message"] == "A warning"
887+
assert isinstance(status["domains"], list)

0 commit comments

Comments
 (0)