diff --git a/actions/cores/customization_core/customization_core.py b/actions/cores/customization_core/customization_core.py index 9219bfe..cbc6337 100644 --- a/actions/cores/customization_core/customization_core.py +++ b/actions/cores/customization_core/customization_core.py @@ -19,6 +19,9 @@ class CustomizationCore(BaseCore): def __init__(self, window_implementation, customization_implementation, row_implementation, *args, **kwargs): # Must be set before create_ui_elements in BaseCore is called self.customization_expander = None + # Flag set during on_remove() to prevent _load_customizations() from + # adding child widgets to the expander while it is detached from the UI. + self._clearing = False super().__init__(*args, **kwargs) self.window_implementation = window_implementation self.customization_implementation = customization_implementation @@ -132,7 +135,25 @@ def _get_attributes(self) -> list[str]: attributes.extend(list(ha_entity.get(customization_const.ATTRIBUTES, {}).keys())) return attributes + @requires_initialization + def on_remove(self) -> None: + """Clean up after action was removed. + + Prevents _load_customizations() from modifying the customization + expander while it is being detached from the UI, which would corrupt + GTK's internal widget-tree state and cause a crash on the next + navigation back to this page. + """ + self._clearing = True + try: + super().on_remove() + finally: + self.customization_expander.clear_rows() + self._clearing = False + def _load_customizations(self) -> None: + if self._clearing: + return self.customization_expander.clear_rows() attributes = self._get_attributes() state = self.plugin_base.backend.get_entity(self.settings.get_entity()) diff --git a/test/actions/cores/customization_core/test_customization_core.py b/test/actions/cores/customization_core/test_customization_core.py index 0969da8..4c86590 100644 --- a/test/actions/cores/customization_core/test_customization_core.py +++ b/test/actions/cores/customization_core/test_customization_core.py @@ -320,6 +320,7 @@ def test_get_attributes(self): def test_load_customizations(self): instance = CustomizationCore.__new__(CustomizationCore) instance.customization_expander = Mock() + instance._clearing = False instance._get_attributes = Mock() instance._get_attributes.return_value = ["attr1", "attr2"] instance.plugin_base = Mock() @@ -348,3 +349,72 @@ def test_load_customizations(self): delete_connect_mock.assert_has_calls([call(base_const.CONNECT_CLICKED, instance._on_delete_customization, 0), call(base_const.CONNECT_CLICKED, instance._on_delete_customization, 1)]) up_connect_mock.assert_has_calls([call(base_const.CONNECT_CLICKED, instance._on_move_up, 0), call(base_const.CONNECT_CLICKED, instance._on_move_up, 1)]) down_connect_mock.assert_has_calls([call(base_const.CONNECT_CLICKED, instance._on_move_down, 0), call(base_const.CONNECT_CLICKED, instance._on_move_down, 1)]) + + def test_load_customizations_skipped_while_clearing(self): + instance = CustomizationCore.__new__(CustomizationCore) + instance.customization_expander = Mock() + instance._clearing = True + + instance._load_customizations() + + instance.customization_expander.clear_rows.assert_not_called() + + @patch('HomeAssistantPlugin.actions.cores.customization_core.customization_core.BaseCore.on_remove') + def test_on_remove_clears_expander_and_resets_flag(self, super_on_remove_mock): + instance = CustomizationCore.__new__(CustomizationCore) + instance.initialized = True + instance._clearing = False + instance.customization_expander = Mock() + + instance.on_remove() + + super_on_remove_mock.assert_called_once() + instance.customization_expander.clear_rows.assert_called_once() + self.assertFalse(instance._clearing) + + @patch('HomeAssistantPlugin.actions.cores.customization_core.customization_core.BaseCore.on_remove') + def test_on_remove_sets_clearing_flag_during_super_call(self, super_on_remove_mock): + """_clearing must be True when super().on_remove() runs so that any + refresh() call triggered by the base class skips _load_customizations().""" + instance = CustomizationCore.__new__(CustomizationCore) + instance.initialized = True + instance._clearing = False + instance.customization_expander = Mock() + + flag_during_super = [] + + def capture_flag(): + flag_during_super.append(instance._clearing) + + super_on_remove_mock.side_effect = capture_flag + + instance.on_remove() + + self.assertTrue(flag_during_super[0]) + self.assertFalse(instance._clearing) + + @patch('HomeAssistantPlugin.actions.cores.customization_core.customization_core.BaseCore.on_remove') + def test_on_remove_clears_expander_even_when_super_raises(self, super_on_remove_mock): + instance = CustomizationCore.__new__(CustomizationCore) + instance.initialized = True + instance._clearing = False + instance.customization_expander = Mock() + super_on_remove_mock.side_effect = RuntimeError("boom") + + with self.assertRaises(RuntimeError): + instance.on_remove() + + instance.customization_expander.clear_rows.assert_called_once() + self.assertFalse(instance._clearing) + + def test_on_remove_not_initialized(self): + instance = CustomizationCore.__new__(CustomizationCore) + instance.initialized = False + instance._clearing = False + instance.customization_expander = Mock() + + result = instance.on_remove() + + self.assertIsNone(result) + instance.customization_expander.clear_rows.assert_not_called() + self.assertFalse(instance._clearing)