mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-12 09:10:35 +02:00
fix(signal combobox): update devices from main thread
This commit is contained in:
@@ -77,6 +77,7 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
|
||||
device_signal_changed = Signal(str)
|
||||
signal_reset = Signal()
|
||||
_device_update_requested = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -137,8 +138,10 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
if self.config.autocomplete:
|
||||
self.autocomplete = True
|
||||
|
||||
self._device_update_register = None
|
||||
self._device_update_requested.connect(self.update_signals_from_filters)
|
||||
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
|
||||
EventType.DEVICE_UPDATE, self.update_signals_from_filters
|
||||
EventType.DEVICE_UPDATE, self._on_device_update
|
||||
)
|
||||
self.currentTextChanged.connect(self.on_text_changed)
|
||||
|
||||
@@ -196,6 +199,13 @@ class SignalComboBox(BECWidget, QComboBox):
|
||||
self.setCurrentText("")
|
||||
self.update_signals_from_filters()
|
||||
|
||||
def _on_device_update(self, action: str, content: dict) -> None:
|
||||
"""Deliver BEC callbacks to the widget thread before accessing Qt models."""
|
||||
if self._device_update_register is None or getattr(self, "_destroyed", False):
|
||||
return
|
||||
if action in ["add", "remove", "reload"]:
|
||||
self._device_update_requested.emit()
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str, dict)
|
||||
def update_signals_from_filters(self, action: str | None = None, content: dict | None = None):
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import get_ident
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.device import Signal
|
||||
from qtpy.QtCore import Qt
|
||||
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||
@@ -56,6 +60,59 @@ def test_signal_combobox_init(device_signal_combobox):
|
||||
assert device_signal_combobox.completer().model() == device_signal_combobox.model()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["add", "remove", "reload"])
|
||||
@pytest.mark.parametrize("class_filter", [False, True])
|
||||
def test_signal_combobox_device_callback_updates_on_gui_thread(
|
||||
qtbot, mocked_client, action, class_filter
|
||||
):
|
||||
mocked_client.device_manager.get_bec_signals = mock.MagicMock(
|
||||
return_value=[("samx", "readback", {"obj_name": "samx_readback"})]
|
||||
)
|
||||
widget = create_widget(
|
||||
qtbot=qtbot,
|
||||
widget=SignalComboBox,
|
||||
client=mocked_client,
|
||||
device="samx",
|
||||
autocomplete=True,
|
||||
signal_class_filter=["AsyncSignal"] if class_filter else None,
|
||||
)
|
||||
expected_signals = widget.signals.copy()
|
||||
widget.signals = []
|
||||
gui_thread = get_ident()
|
||||
rebuild_threads = []
|
||||
model_threads = []
|
||||
original_replace = widget._replace_signal_items
|
||||
|
||||
def record_rebuild(*args, **kwargs):
|
||||
rebuild_threads.append(get_ident())
|
||||
# Record a regression without actually mutating Qt models from a worker.
|
||||
assert get_ident() == gui_thread
|
||||
original_replace(*args, **kwargs)
|
||||
|
||||
def record_model_change(*_args):
|
||||
model_threads.append(get_ident())
|
||||
|
||||
widget.model().rowsInserted.connect(record_model_change, Qt.ConnectionType.DirectConnection)
|
||||
widget._completer_model.modelReset.connect(
|
||||
record_model_change, Qt.ConnectionType.DirectConnection
|
||||
)
|
||||
with mock.patch.object(widget, "_replace_signal_items", side_effect=record_rebuild):
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(
|
||||
widget.bec_dispatcher.client.callbacks.run,
|
||||
EventType.DEVICE_UPDATE,
|
||||
action=action,
|
||||
content={},
|
||||
).result(timeout=5)
|
||||
assert rebuild_threads == []
|
||||
qtbot.waitUntil(lambda: bool(rebuild_threads))
|
||||
|
||||
assert rebuild_threads == [gui_thread]
|
||||
assert model_threads and all(thread == gui_thread for thread in model_threads)
|
||||
assert widget.signals == expected_signals
|
||||
assert widget._completer_model.stringList() == signal_names(expected_signals)
|
||||
|
||||
|
||||
def test_signal_combobox_config_defaults_are_independent_lists():
|
||||
config_a = SignalComboBoxConfig(widget_class="SignalComboBox")
|
||||
config_b = SignalComboBoxConfig(widget_class="SignalComboBox")
|
||||
@@ -233,7 +290,7 @@ def test_signal_combobox_cleanup_blocks_in_flight_device_update(qtbot, mocked_cl
|
||||
callback_id = widget._device_update_register
|
||||
|
||||
def trigger_in_flight_update(_):
|
||||
widget.update_signals_from_filters("reload", {})
|
||||
widget._on_device_update("reload", {})
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
@@ -247,11 +304,27 @@ def test_signal_combobox_cleanup_blocks_in_flight_device_update(qtbot, mocked_cl
|
||||
set_signal_groups.assert_not_called()
|
||||
|
||||
|
||||
def test_signal_combobox_cleanup_blocks_queued_device_update(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client, device="samx")
|
||||
callbacks = widget.bec_dispatcher.client.callbacks
|
||||
callback_id = widget._device_update_register
|
||||
with mock.patch.object(widget, "_set_signal_groups") as set_signal_groups:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(callbacks.run, EventType.DEVICE_UPDATE, "reload", {}).result(timeout=5)
|
||||
widget.cleanup()
|
||||
qtbot.wait(10)
|
||||
callbacks.run(EventType.DEVICE_UPDATE, "reload", {})
|
||||
|
||||
assert callback_id not in callbacks.callbacks
|
||||
set_signal_groups.assert_not_called()
|
||||
|
||||
|
||||
def test_signal_combobox_device_update_ignores_update_action(qtbot, mocked_client):
|
||||
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
|
||||
|
||||
with mock.patch.object(widget, "_set_signal_groups") as set_signal_groups:
|
||||
widget.update_signals_from_filters("update", {})
|
||||
widget.bec_dispatcher.client.callbacks.run(EventType.DEVICE_UPDATE, "update", {})
|
||||
|
||||
set_signal_groups.assert_not_called()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user