mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 08:01:29 +01:00
fix(widgets): skip deferred single shots of closed widgets
This commit is contained in:
1 parent
fd8707d7f5
commit
87b33bc355
10 files changed
+112
-10
No files matched your search
+4
-1
@@ -311,8 +311,11 @@ class DeviceFormDialog(QtWidgets.QDialog):
|
||||
# this is relevant for the _wait_dialog exec which opens a modal dialog during validation
|
||||
# and therefore must not have the signal emitted immediately in the same event loop iteration.
|
||||
# Otherwise, the callback may be scheduled before the dialog is shown resulting in a deadlock.
|
||||
# Bound to the validation widget: Qt drops the call if it is deleted before it runs.
|
||||
QtCore.QTimer.singleShot(
|
||||
0, lambda: ophyd_validation.change_device_configs([config], True, False)
|
||||
0,
|
||||
ophyd_validation,
|
||||
lambda: ophyd_validation.change_device_configs([config], True, False),
|
||||
)
|
||||
return ophyd_validation
|
||||
|
||||
|
||||
@@ -547,7 +547,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
|
||||
self.search_controls.addLayout(self.search_layout)
|
||||
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
|
||||
self.search_controls.addLayout(self.fuzzy_layout)
|
||||
QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0))
|
||||
self._call_later(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Row Management, internal methods.
|
||||
|
||||
@@ -11,7 +11,7 @@ from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal
|
||||
from qtpy.QtCore import QObject, Qt, QThread, Signal
|
||||
from qtpy.QtGui import QTransform
|
||||
from qtpy.QtWidgets import QDialog, QPushButton, QVBoxLayout
|
||||
from toolz import partition
|
||||
@@ -668,8 +668,9 @@ class Heatmap(ImageBase):
|
||||
self.sync_signal_update.emit()
|
||||
status = msg.get("done")
|
||||
if status:
|
||||
QTimer.singleShot(100, self.update_plot)
|
||||
QTimer.singleShot(300, self.update_plot)
|
||||
# Final refreshes for late scan data; skipped if the widget is closed meanwhile.
|
||||
self._call_later(100, self.update_plot)
|
||||
self._call_later(300, self.update_plot)
|
||||
|
||||
@SafeSlot()
|
||||
def update_plot(self, _=None) -> None:
|
||||
|
||||
@@ -12,7 +12,7 @@ from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_obj
|
||||
from bec_lib.scan_data_container import ScanDataContainer
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import Qt, QTimer, Signal
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
@@ -1180,9 +1180,8 @@ class Waveform(PlotBase):
|
||||
if config.source == "history":
|
||||
self._history_curves.append(curve)
|
||||
|
||||
QTimer.singleShot(
|
||||
150, self.auto_range
|
||||
) # autorange with a delay to ensure the plot is updated
|
||||
# Autorange with a delay to ensure the plot is updated; skipped if the widget is closed.
|
||||
self._call_later(150, self.auto_range)
|
||||
self._refresh_alignment_state()
|
||||
|
||||
return curve
|
||||
|
||||
@@ -874,7 +874,7 @@ class LogPanel(BECWidget, QWidget):
|
||||
# defer: when the current row moves because of a buffer trim, this signal fires
|
||||
# while the trimmed rows are still present - only after the update settles can we
|
||||
# tell a user-driven move from the selection model relocating a removed row
|
||||
QTimer.singleShot(0, self._sync_detail_to_selection)
|
||||
self._call_later(0, self._sync_detail_to_selection)
|
||||
|
||||
@SafeSlot()
|
||||
def _sync_detail_to_selection(self):
|
||||
|
||||
@@ -685,6 +685,21 @@ class TestDeviceTable:
|
||||
assert len(selected_configs) == 1
|
||||
assert list(selected_configs[0].keys())[0] in ["motor_x", "detector_main"]
|
||||
|
||||
def test_initial_fuzzy_sync_skipped_when_closed_first(self, qtbot, mocked_client):
|
||||
"""
|
||||
The table defers its initial fuzzy-search sync to the event loop: it runs for an open
|
||||
table and is skipped for a table closed before the event loop gets to it.
|
||||
"""
|
||||
with mock.patch.object(DeviceTable, "_state_change_fuzzy_search") as fuzzy_sync:
|
||||
open_table = DeviceTable(client=mocked_client)
|
||||
closed_table = DeviceTable(client=mocked_client)
|
||||
qtbot.addWidget(open_table)
|
||||
qtbot.addWidget(closed_table)
|
||||
closed_table.close() # closeEvent -> cleanup() before the deferred sync runs
|
||||
|
||||
qtbot.wait(50)
|
||||
fuzzy_sync.assert_called_once_with(0)
|
||||
|
||||
|
||||
class TestOphydValidation:
|
||||
"""
|
||||
|
||||
@@ -308,6 +308,21 @@ class TestDeviceManagerViewDialogs:
|
||||
mock_create_dialog.assert_not_called()
|
||||
mock_create_validation.assert_not_called()
|
||||
|
||||
def test_device_form_validation_start_dropped_with_validation_widget(
|
||||
self, device_form_dialog: DeviceFormDialog, qtbot
|
||||
):
|
||||
"""
|
||||
The dialog starts the ophyd validation from the event loop; the deferred start is
|
||||
bound to the validation widget, so it is dropped if that widget is deleted first.
|
||||
"""
|
||||
with mock.patch.object(OphydValidation, "change_device_configs") as change_configs:
|
||||
validation = device_form_dialog._create_and_run_ophyd_validation({"name": "dev"})
|
||||
validation.deleteLater()
|
||||
QtWidgets.QApplication.sendPostedEvents(None, QtCore.QEvent.Type.DeferredDelete)
|
||||
|
||||
qtbot.wait(50)
|
||||
change_configs.assert_not_called()
|
||||
|
||||
def test_device_status_item(self, device_config: dict, qtbot):
|
||||
"""Test the DeviceStatusItem widget."""
|
||||
item = DeviceStatusItem(device_config=device_config, config_status=0, connection_status=0)
|
||||
|
||||
@@ -1193,3 +1193,32 @@ def test_heatmap_cleanup_detaches_update_proxy(qtbot, mocked_client):
|
||||
|
||||
qtbot.wait(300)
|
||||
update_plot.assert_not_called()
|
||||
|
||||
|
||||
def test_heatmap_scan_done_refreshes_skipped_after_close(qtbot, mocked_client):
|
||||
"""
|
||||
A finished scan schedules two delayed final refreshes: they run while the Heatmap is open
|
||||
and are skipped once it has been closed (cleaned up) before they fire.
|
||||
"""
|
||||
with mock.patch.object(Heatmap, "update_plot") as update_plot:
|
||||
open_hm = Heatmap(client=mocked_client)
|
||||
qtbot.addWidget(open_hm)
|
||||
qtbot.waitExposed(open_hm)
|
||||
qtbot.wait(100) # let anything queued during construction drain
|
||||
update_plot.reset_mock()
|
||||
|
||||
open_hm.on_scan_progress({"done": True}, {})
|
||||
# one proxied update plus the 100 ms and 300 ms refreshes
|
||||
qtbot.waitUntil(lambda: update_plot.call_count == 3, timeout=2000)
|
||||
|
||||
closed_hm = Heatmap(client=mocked_client)
|
||||
qtbot.addWidget(closed_hm)
|
||||
qtbot.waitExposed(closed_hm)
|
||||
qtbot.wait(100)
|
||||
update_plot.reset_mock()
|
||||
|
||||
closed_hm.on_scan_progress({"done": True}, {}) # schedules the refreshes
|
||||
closed_hm.close() # closeEvent -> cleanup() before they fire
|
||||
|
||||
qtbot.wait(450)
|
||||
update_plot.assert_not_called()
|
||||
@@ -559,6 +559,25 @@ def test_log_panel_font_size_property(qtbot, log_panel: LogPanel):
|
||||
assert log_panel._table.columnWidth(0) == default_level_width
|
||||
|
||||
|
||||
def test_log_panel_deferred_detail_sync_skipped_after_close(qtbot, log_panel: LogPanel):
|
||||
"""
|
||||
A current-row change defers the detail-pane sync to the event loop: it runs while the
|
||||
panel is open and is skipped once the panel has been closed before it is due.
|
||||
"""
|
||||
log_panel._table.setCurrentIndex(log_panel._proxy.index(1, 0))
|
||||
log_panel._show_details()
|
||||
assert not log_panel._detail.isHidden()
|
||||
with patch.object(log_panel, "_sync_detail_to_selection") as sync_detail:
|
||||
log_panel._on_current_row_changed()
|
||||
qtbot.waitUntil(lambda: sync_detail.call_count == 1, timeout=500)
|
||||
|
||||
log_panel._on_current_row_changed()
|
||||
log_panel.close() # closeEvent -> cleanup() before the deferred sync runs
|
||||
|
||||
qtbot.wait(50)
|
||||
assert sync_detail.call_count == 1
|
||||
|
||||
|
||||
def test_log_panel_custom_range_dialog_prefills_active_bounds(qtbot, log_panel: LogPanel):
|
||||
toolbar = log_panel._toolbar
|
||||
toolbar._apply_time_preset("Last 5 min", 300)
|
||||
|
||||
@@ -706,6 +706,27 @@ def test_waveform_scan_done_refreshes_skipped_after_cleanup(qtbot, waveform_with
|
||||
assert slots["sync"].call_count == 0
|
||||
|
||||
|
||||
def test_waveform_add_curve_autorange_skipped_after_close(qtbot, mocked_client):
|
||||
"""
|
||||
Adding a curve schedules a delayed auto-range: it runs while the Waveform is open and is
|
||||
skipped once the Waveform has been closed (cleaned up) before the delay elapsed.
|
||||
"""
|
||||
open_wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
closed_wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
with (
|
||||
mock.patch.object(open_wf, "auto_range") as open_auto_range,
|
||||
mock.patch.object(closed_wf, "auto_range") as closed_auto_range,
|
||||
):
|
||||
open_wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="open_curve")
|
||||
closed_wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="closed_curve")
|
||||
calls_before_close = closed_auto_range.call_count
|
||||
closed_wf.close() # closeEvent -> cleanup() before the auto-range is due
|
||||
|
||||
qtbot.waitUntil(lambda: open_auto_range.call_count > 0, timeout=1000)
|
||||
qtbot.wait(250)
|
||||
assert closed_auto_range.call_count == calls_before_close
|
||||
|
||||
|
||||
def test_request_dap_always_resubmits_device_parent(qtbot, mocked_client_with_dap, monkeypatch):
|
||||
"""
|
||||
DAP curves attached to device curves keep the resubmit-on-every-update behavior,
|
||||
|
||||
Reference in new issue
Block a user