diff --git a/bec_widgets/applications/__init__.py b/bec_widgets/applications/__init__.py index e39a9c37..10f8bef3 100644 --- a/bec_widgets/applications/__init__.py +++ b/bec_widgets/applications/__init__.py @@ -1,13 +1,5 @@ -import os -import sys - import bec_widgets.widgets.containers.qt_ads as QtAds -if sys.platform.startswith("linux"): - qt_platform = os.environ.get("QT_QPA_PLATFORM", "") - if qt_platform != "offscreen": - os.environ["QT_QPA_PLATFORM"] = "xcb" - # Default QtAds configuration QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True) QtAds.CDockManager.setConfigFlag( diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index eaa1efae..82cf2a27 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -405,7 +405,7 @@ class BECWidget(BECConnector): """Wrap the close even to ensure the rpc_register is cleaned up.""" try: if not self._destroyed: - self.cleanup() self._destroyed = True + self.cleanup() finally: super().closeEvent(event) # pylint: disable=no-member diff --git a/bec_widgets/utils/crosshair.py b/bec_widgets/utils/crosshair.py index f7a3d10d..2ddfed4e 100644 --- a/bec_widgets/utils/crosshair.py +++ b/bec_widgets/utils/crosshair.py @@ -529,10 +529,10 @@ class Crosshair(QObject): if event is None: return # nothing to do scene_pos = event[0] # SignalProxy bundle - if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos): - return view_pos = self.plot_item.vb.mapSceneToView(scene_pos) x, y = view_pos.x(), view_pos.y() + if not self._is_within_view_range(x, y): + return # Update cross‑hair visuals self.v_line.setPos(x) @@ -594,8 +594,12 @@ class Crosshair(QObject): if event.button() != Qt.MouseButton.LeftButton: return self.update_markers() - if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos): - mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos) + scene_pos_getter = getattr(event, "scenePos", None) + if not callable(scene_pos_getter): + return + scene_pos = scene_pos_getter() + mouse_point = self.plot_item.vb.mapSceneToView(scene_pos) + if self._is_within_view_range(mouse_point.x(), mouse_point.y()): x, y = mouse_point.x(), mouse_point.y() # Keep the raw click position; ``x``/``y`` are reused/snapped below. pin_x, pin_y = mouse_point.x(), mouse_point.y() @@ -652,7 +656,7 @@ class Crosshair(QObject): # removes it, otherwise (re)place a single pin at the clicked point. if event.double(): self.clear_pin() - elif self._pin_hit(event._scenePos): + elif self._pin_hit(scene_pos): self.clear_pin() else: self.set_pin(pin_x, pin_y, x_snap_values, y_snap_values) @@ -853,6 +857,10 @@ class Crosshair(QObject): delta = pin_scene - scene_pos return (delta.x() ** 2 + delta.y() ** 2) ** 0.5 <= self._pin_hit_radius_px + def _is_within_view_range(self, x: float, y: float) -> bool: + x_range, y_range = self.plot_item.vb.viewRange() + return min(x_range) <= x <= max(x_range) and min(y_range) <= y <= max(y_range) + def _get_transformed_position( self, x: float, y: float, transform: QTransform ) -> tuple[QPointF, QPointF]: diff --git a/bec_widgets/utils/forms_from_types/styles.py b/bec_widgets/utils/forms_from_types/styles.py index 8a77ab16..61ab48f8 100644 --- a/bec_widgets/utils/forms_from_types/styles.py +++ b/bec_widgets/utils/forms_from_types/styles.py @@ -1,12 +1,19 @@ -import bec_qthemes +from bec_qthemes.qss_editor.qss_editor import ( + THEMES_PATH, + build_palette_from_mapping, + read_theme_xml, +) def pretty_display_theme(theme: str = "dark"): - palette = bec_qthemes.load_palette(theme) + _, mapping = read_theme_xml(THEMES_PATH / f"{theme}.xml") + palette = build_palette_from_mapping(mapping) foreground = palette.text().color().name() background = palette.base().color().name() border = palette.shadow().color().name() - accent = palette.accent().color().name() + # palette.highlight() rather than accent(): on Qt 6.10+ accent() returns the + # platform accent, and bec_qthemes palettes only set Highlight anyway. + accent = palette.highlight().color().name() return f""" QWidget {{color: {foreground}; background-color: {background}}} QLabel {{ font-weight: bold; }} diff --git a/bec_widgets/widgets/containers/dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/dock_area/basic_dock_area.py index 6e6344dd..1c73651d 100644 --- a/bec_widgets/widgets/containers/dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/basic_dock_area.py @@ -1480,6 +1480,15 @@ class DockAreaWidget(BECWidget, QWidget): for dock in self.dock_list(): self._delete_dock(dock) + def cleanup(self): + """Tear down all docks via the Qt ADS API before the base BECWidget cleanup runs. + + Explicitly releasing dock widgets through the CDockManager API first prevents its + destructor from interacting badly with dock widgets that are deleted outside of it. + """ + self.delete_all() + super().cleanup() + if __name__ == "__main__": # pragma: no cover import sys diff --git a/bec_widgets/widgets/editors/bec_console/bec_console.py b/bec_widgets/widgets/editors/bec_console/bec_console.py index c57ca736..6a41677b 100644 --- a/bec_widgets/widgets/editors/bec_console/bec_console.py +++ b/bec_widgets/widgets/editors/bec_console/bec_console.py @@ -144,7 +144,12 @@ class BecConsoleRegistry: return None window = console.window() - if window is not None and window is not console and self._is_valid_qobject(window): + if ( + window is not None + and window is not console + and self._is_valid_qobject(window) + and not getattr(window, "_destroyed", False) + ): return window if not avoid_console: diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index 432a89cb..70b8a422 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -11,7 +11,7 @@ from bec_lib.endpoints import MessageEndpoints from bec_lib.utils.import_utils import lazy_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, QRectF, Qt, QThread, QTimer, Signal from qtpy.QtGui import QTransform from qtpy.QtWidgets import QDialog, QPushButton, QVBoxLayout from toolz import partition @@ -93,6 +93,30 @@ class HeatmapConfig(ConnectionConfig): _validate_color_palette = field_validator("color_map")(Colors.validate_color_map) +class _TextOnlyLegendSample(pg.graphicsItems.LegendItem.ItemSample): + """Zero-size legend sample for text-only rows in the config label. + + The stock ItemSample expects a plottable item with an ``opts`` dict; since + PySide 6.10 an exception raised inside its paint() override propagates out + of the C++ paint loop and crashes the application, so the config label rows + must not carry a real sample item. + """ + + def __init__(self): + super().__init__(item=None) + self.setFixedWidth(0) + self.setFixedHeight(0) + + def boundingRect(self): + return QRectF(0, 0, 0, 0) + + def paint(self, p, *args): + pass + + def mouseClickEvent(self, event): + event.ignore() + + @dataclass class _InterpolationRequest: """Immutable payload describing an interpolation request for the worker thread. @@ -977,14 +1001,14 @@ class Heatmap(ImageBase): self.config_label.clear() # Indicate whether the widget follows the live acquisition or is pinned to a history scan mode = "history" if self._history_scan_id is not None else "live" - self.config_label.addItem(self.plot_item, f"Scan: {scan_msg.scan_number} ({mode})") - self.config_label.addItem(self.plot_item, f"Scan Name: {scan_msg.scan_name}") + self.config_label.addItem(_TextOnlyLegendSample(), f"Scan: {scan_msg.scan_number} ({mode})") + self.config_label.addItem(_TextOnlyLegendSample(), f"Scan Name: {scan_msg.scan_name}") if scan_msg.scan_name != "grid_scan" or self._image_config.enforce_interpolation: self.config_label.addItem( - self.plot_item, f"Interpolation: {self._image_config.interpolation}" + _TextOnlyLegendSample(), f"Interpolation: {self._image_config.interpolation}" ) self.config_label.addItem( - self.plot_item, f"Oversampling: {self._image_config.oversampling_factor}x" + _TextOnlyLegendSample(), f"Oversampling: {self._image_config.oversampling_factor}x" ) def get_image_data( diff --git a/bec_widgets/widgets/utility/spinner/spinner.py b/bec_widgets/widgets/utility/spinner/spinner.py index ab5dadd1..d95d951c 100644 --- a/bec_widgets/widgets/utility/spinner/spinner.py +++ b/bec_widgets/widgets/utility/spinner/spinner.py @@ -58,7 +58,9 @@ class SpinnerWidget(QWidget): color_palette = get_theme_palette() - color = QColor(color_palette.accent().color()) + # Use the theme highlight color; palette.accent() resolved to it before Qt 6.10, + # but now falls back to the platform accent since the theme does not set the role. + color = QColor(color_palette.highlight().color()) rect.adjust(line_width, line_width, -line_width, -line_width) diff --git a/pyproject.toml b/pyproject.toml index d2a72751..5e5f421e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,8 @@ classifiers = [ ] dependencies = [ "PyJWT~=2.9", - "PySide6==6.9.0", - "PySide6-QtAds==4.4.0", + "PySide6==6.11.1", + "PySide6-QtAds==5.0.0", "bec_ipython_client~=3.134", # needed for jupyter console "bec_lib~=3.134", "bec_qthemes~=1.0, >=1.3.4", @@ -23,7 +23,7 @@ dependencies = [ "ophyd_devices~=1.29, >=1.29.1", "pydantic~=2.0", "pylsp-bec~=1.2", - "pyqtgraph==0.13.7", + "pyqtgraph==0.14.0", "python-slugify~=8.0", "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtmonaco~=0.8, >=0.8.1", @@ -57,7 +57,7 @@ dev = [ "watchdog~=6.0", "pre_commit~=4.2", ] -qtermwidget = ["pyside6_qtermwidget"] +qtermwidget = ["pyside6_qtermwidget==0.5.6.11.1"] diff --git a/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png b/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png index 85c5a244..51423081 100644 Binary files a/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png and b/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png differ diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index e1bce53d..e97ff5bd 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -68,6 +68,10 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus # stop pyepics dispatcher for leaking tests _dispatcher.stop() + from bec_widgets.widgets.editors.bec_console.bec_console import _bec_console_registry + + _bec_console_registry.clear() + process_all_deferred_deletes(qapp) if request.node.stash._storage.get("failed"): print("Test failed, skipping cleanup checks") return diff --git a/tests/unit_tests/test_crosshair.py b/tests/unit_tests/test_crosshair.py index 05f1a4db..8ea554ab 100644 --- a/tests/unit_tests/test_crosshair.py +++ b/tests/unit_tests/test_crosshair.py @@ -14,6 +14,33 @@ from .conftest import create_widget # pylint: disable = redefined-outer-name +class _FakeClickEvent: + """Minimal public-API stand-in for a pyqtgraph MouseClickEvent.""" + + def __init__( + self, + scene_pos: QPointF, + button: Qt.MouseButton = Qt.MouseButton.LeftButton, + double: bool = False, + ): + self._scene_pos = scene_pos + self._button = button + self._double = double + self.accepted = False + + def button(self): + return self._button + + def scenePos(self): + return self._scene_pos + + def double(self): + return self._double + + def accept(self): + self.accepted = True + + @pytest.fixture def plot_widget_with_crosshair(qtbot): widget = pg.PlotWidget() @@ -22,6 +49,7 @@ def plot_widget_with_crosshair(qtbot): widget.plot(x=[1, 2, 3], y=[4, 5, 6], name="Curve 1") plot_item = widget.getPlotItem() + plot_item.vb.setRange(xRange=(0, 4), yRange=(0, 10), padding=0) crosshair = Crosshair(plot_item=plot_item, precision=3) yield crosshair, plot_item @@ -38,20 +66,17 @@ def image_widget_with_crosshair(qtbot): widget.addItem(image_item) plot_item = widget.getPlotItem() + plot_item.vb.setRange(xRange=(0, 100), yRange=(0, 100), padding=0) crosshair = Crosshair(plot_item=plot_item, precision=3) yield crosshair, plot_item def test_mouse_moved_lines(plot_widget_with_crosshair): - crosshair, plot_item = plot_widget_with_crosshair - - pos_in_view = QPointF(2, 5) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] + crosshair, _ = plot_widget_with_crosshair # Simulate mouse movement - crosshair.mouse_moved(event_mock) + crosshair.mouse_moved(manual_pos=(2, 5)) # Check that the vertical line is indeed at x=2 assert np.isclose(crosshair.v_line.pos().x(), 2) @@ -59,7 +84,7 @@ def test_mouse_moved_lines(plot_widget_with_crosshair): def test_mouse_moved_signals(plot_widget_with_crosshair): - crosshair, plot_item = plot_widget_with_crosshair + crosshair, _ = plot_widget_with_crosshair emitted_values_1D = [] @@ -68,43 +93,40 @@ def test_mouse_moved_signals(plot_widget_with_crosshair): crosshair.coordinatesChanged1D.connect(slot) - pos_in_view = QPointF(2, 5) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - - crosshair.mouse_moved(event_mock) + crosshair.mouse_moved(manual_pos=(2, 5)) # Assert the expected behavior assert emitted_values_1D == [("Curve 1", 2, 5)] def test_mouse_moved_signals_outside(plot_widget_with_crosshair): - crosshair, plot_item = plot_widget_with_crosshair + crosshair, _ = plot_widget_with_crosshair # Create a slot that will store the emitted values as tuples emitted_values_1D = [] + emitted_positions = [] def slot(coordinates): emitted_values_1D.append(coordinates) # Connect the signal to the custom slot crosshair.coordinatesChanged1D.connect(slot) + crosshair.crosshairChanged.connect(emitted_positions.append) - # Simulate a mouse moved event at a specific position - pos_in_view = QPointF(22, 55) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - - # Call the mouse_moved method - crosshair.mouse_moved(event_mock) + crosshair.mouse_moved(manual_pos=(2, 5)) + emitted_positions.clear() + emitted_values_1D.clear() + crosshair.mouse_moved(manual_pos=(22, 55)) # Assert the expected behavior assert emitted_values_1D == [] + assert emitted_positions == [] + assert np.isclose(crosshair.v_line.pos().x(), 2) + assert np.isclose(crosshair.h_line.pos().y(), 5) def test_mouse_moved_signals_2D(image_widget_with_crosshair): - crosshair, plot_item = image_widget_with_crosshair - image_item = plot_item.items[0] + crosshair, _ = image_widget_with_crosshair emitted_values_2D = [] @@ -113,17 +135,16 @@ def test_mouse_moved_signals_2D(image_widget_with_crosshair): crosshair.coordinatesChanged2D.connect(slot) - pos_in_view = QPointF(21.0, 55.0) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - - crosshair.mouse_moved(event_mock) + crosshair.mouse_moved(manual_pos=(21.0, 55.0)) assert emitted_values_2D == [("ImageItem", 21, 55)] -def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair): +def test_mouse_moved_signals_2D_outside_image_bounds_clamps_inside_view_range( + image_widget_with_crosshair, +): crosshair, plot_item = image_widget_with_crosshair + plot_item.vb.setRange(xRange=(0, 300), yRange=(0, 600), padding=0) emitted_values_2D = [] @@ -132,23 +153,34 @@ def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair): crosshair.coordinatesChanged2D.connect(slot) - pos_in_view = QPointF(220.0, 555.0) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] + crosshair.mouse_moved(manual_pos=(220.0, 555.0)) - crosshair.mouse_moved(event_mock) + assert emitted_values_2D == [("ImageItem", 99, 99)] + + +def test_mouse_moved_signals_2D_outside_view_range_ignored(image_widget_with_crosshair): + crosshair, _ = image_widget_with_crosshair + + emitted_values_2D = [] + emitted_positions = [] + + crosshair.coordinatesChanged2D.connect(emitted_values_2D.append) + crosshair.crosshairChanged.connect(emitted_positions.append) + + crosshair.mouse_moved(manual_pos=(21.0, 55.0)) + emitted_positions.clear() + emitted_values_2D.clear() + crosshair.mouse_moved(manual_pos=(220.0, 555.0)) assert emitted_values_2D == [] + assert emitted_positions == [] + assert np.isclose(crosshair.v_line.pos().x(), 21.0) + assert np.isclose(crosshair.h_line.pos().y(), 55.0) def test_marker_positions_after_mouse_move(plot_widget_with_crosshair): - crosshair, plot_item = plot_widget_with_crosshair - - pos_in_view = QPointF(2, 5) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - - crosshair.mouse_moved(event_mock) + crosshair, _ = plot_widget_with_crosshair + crosshair.mouse_moved(manual_pos=(2, 5)) marker = crosshair.marker_moved_1d["Curve 1"] marker_x, marker_y = marker.getData() @@ -172,7 +204,7 @@ def test_scale_emitted_coordinates(plot_widget_with_crosshair): def test_crosshair_changed_signal(plot_widget_with_crosshair): - crosshair, plot_item = plot_widget_with_crosshair + crosshair, _ = plot_widget_with_crosshair emitted_positions = [] @@ -181,11 +213,7 @@ def test_crosshair_changed_signal(plot_widget_with_crosshair): crosshair.crosshairChanged.connect(slot) - pos_in_view = QPointF(2, 5) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - - crosshair.mouse_moved(event_mock) + crosshair.mouse_moved(manual_pos=(2, 5)) x, y = emitted_positions[0] @@ -193,33 +221,33 @@ def test_crosshair_changed_signal(plot_widget_with_crosshair): assert np.isclose(y, 5) -def test_crosshair_clicked_signal(qtbot, plot_widget_with_crosshair): +def test_crosshair_clicked_signal(plot_widget_with_crosshair): crosshair, plot_item = plot_widget_with_crosshair emitted_positions = [] + emitted_view_positions = [] def slot(position): emitted_positions.append(position) crosshair.crosshairClicked.connect(slot) + crosshair.positionClicked.connect(emitted_view_positions.append) - x_data = 2 - y_data = 5 + crosshair.is_log_x = True + crosshair.is_log_y = True + plot_item.vb.setRange(xRange=(0, 1), yRange=(0, 1), padding=0) - # Map data coordinates to scene coordinates - pos_in_scene = plot_item.vb.mapViewToScene(QPointF(x_data, y_data)) - # Map scene coordinates to widget coordinates - graphics_view = plot_item.vb.scene().views()[0] - qtbot.waitExposed(graphics_view) - pos_in_widget = graphics_view.mapFromScene(pos_in_scene) - - # Simulate mouse click - qtbot.mouseClick(graphics_view.viewport(), Qt.LeftButton, pos=pos_in_widget) + known_view_point = QPointF(np.log10(2), np.log10(5)) + pos_in_scene = plot_item.vb.mapViewToScene(known_view_point) + crosshair.mouse_clicked(_FakeClickEvent(pos_in_scene)) x, y = emitted_positions[0] + view_x, view_y = emitted_view_positions[0] - assert np.isclose(round(x, 1), 2) - assert np.isclose(round(y, 1), 5) + assert np.isclose(x, 2) + assert np.isclose(y, 5) + assert np.isclose(view_x, known_view_point.x()) + assert np.isclose(view_y, known_view_point.y()) def test_update_coord_label_1D(plot_widget_with_crosshair): @@ -388,21 +416,18 @@ def test_ignore_invisible_curves_on_move(qtbot, mocked_client): c0 = wf.plot(x=[1, 2, 3], y=[1, 4, 9], name="Curve_0") c1 = wf.plot(x=[1, 2, 3], y=[2, 5, 10], name="Curve_1") wf.hook_crosshair() + wf.crosshair.plot_item.vb.setRange(xRange=(0, 4), yRange=(0, 10), padding=0) # # Simulate a mouse move at (2,5) - pos_in_view = QPointF(2, 5) - pos_in_scene = wf.plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - # 1) Both curves visible: expect markers for both wf.crosshair.clear_markers() - wf.crosshair.mouse_moved(event_mock) + wf.crosshair.mouse_moved(manual_pos=(2, 5)) assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0", "Curve_1"} # 2) Hide Curve B and repeat: only Curve_0 should remain c1.setVisible(False) wf.crosshair.clear_markers() - wf.crosshair.mouse_moved(event_mock) + wf.crosshair.mouse_moved(manual_pos=(2, 5)) qtbot.wait(200) assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0"} @@ -412,25 +437,6 @@ def test_ignore_invisible_curves_on_move(qtbot, mocked_client): ############################################### -class _FakeClickEvent: - """Minimal stand-in for a pyqtgraph MouseClickEvent for routing tests.""" - - def __init__(self, scene_pos, button=Qt.LeftButton, double=False): - self._scenePos = scene_pos - self._button = button - self._double = double - self.accepted = False - - def button(self): - return self._button - - def double(self): - return self._double - - def accept(self): - self.accepted = True - - def test_set_pin_1d_creates_marker_and_emits(plot_widget_with_crosshair): crosshair, _ = plot_widget_with_crosshair pinned = [] diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index 8bb3288c..4f97dbfa 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -531,6 +531,7 @@ class TestDeviceManagerView: # Assign the mocked client widget.device_manager_widget.client = mocked_client qtbot.addWidget(widget) + widget.show() qtbot.waitExposed(widget) yield widget diff --git a/tests/unit_tests/test_heatmap_widget.py b/tests/unit_tests/test_heatmap_widget.py index 571f1077..4d0092fb 100644 --- a/tests/unit_tests/test_heatmap_widget.py +++ b/tests/unit_tests/test_heatmap_widget.py @@ -13,6 +13,7 @@ from bec_widgets.widgets.plots.heatmap.heatmap import ( HeatmapDeviceSignal, _InterpolationRequest, _StepInterpolationWorker, + _TextOnlyLegendSample, ) # pytest: disable=unused-import @@ -1055,6 +1056,27 @@ def test_heatmap_config_label_shows_live_or_history(heatmap_widget): assert "Scan: 5 (history)" in labels +def test_heatmap_config_label_paints_without_error(heatmap_widget): + """The config label rows must survive a real paint pass. + + A stock ItemSample around a PlotItem raises in paint(); since PySide 6.10 + override exceptions propagate out of the C++ paint loop and segfault. + """ + scan_msg = mock.MagicMock() + scan_msg.scan_number = 5 + scan_msg.scan_name = "line_scan" + heatmap_widget.status_message = scan_msg + heatmap_widget._image_config.show_config_label = True + heatmap_widget.redraw_config_label() + + samples = [sample for sample, _ in heatmap_widget.config_label.items] + assert samples + assert all(isinstance(sample, _TextOnlyLegendSample) for sample in samples) + + pixmap = heatmap_widget.grab() + assert not pixmap.isNull() + + def test_heatmap_settings_scan_index_syncs_with_widget(heatmap_widget, qtbot, scan_history_factory): """ The scan index combobox in the settings dialog mirrors the heatmap state: diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 46f9a3a1..5e82c47d 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -1391,6 +1391,9 @@ def test_crosshair_moves_update_profiles_immediately(qtbot, mocked_client): switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair") switch.actions["crosshair_roi"].action.trigger() qtbot.wait(50) + bec_image_view.crosshair.plot_item.vb.setRange( + xRange=(0, test_data.shape[0]), yRange=(0, test_data.shape[1]), padding=0 + ) # Position the crosshair lines and emit the snapped pixel (as mouse_moved does). bec_image_view.crosshair.mouse_moved(manual_pos=(1.2, 2.2)) @@ -1410,6 +1413,9 @@ def test_live_label_intensity_updates_on_image_update(qtbot, mocked_client): test_data = np.arange(25, dtype=float).reshape(5, 5) bec_image_view.on_image_update_2d({"data": test_data}, {}) bec_image_view.hook_crosshair() + bec_image_view.crosshair.plot_item.vb.setRange( + xRange=(0, test_data.shape[0]), yRange=(0, test_data.shape[1]), padding=0 + ) bec_image_view.crosshair.mouse_moved(manual_pos=(2.5, 3.5)) assert "Intensity: 13.000" in bec_image_view.crosshair.coord_label.toPlainText() diff --git a/tests/unit_tests/test_widget_io.py b/tests/unit_tests/test_widget_io.py index 6cf86fdd..dd3f1d12 100644 --- a/tests/unit_tests/test_widget_io.py +++ b/tests/unit_tests/test_widget_io.py @@ -80,7 +80,7 @@ def test_export_import_config(example_widget): "value": 10, }, "QTableWidget ()": { - "QAbstractButton ()": {}, + "QAbstractButton (qt_tableview_cornerbutton)": {}, "QAbstractTableModel ()": {}, "QHeaderView ()": { "QItemSelectionModel ()": {},