Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions actions/cores/customization_core/customization_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down
70 changes: 70 additions & 0 deletions test/actions/cores/customization_core/test_customization_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Loading