1111
1212from 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