From 3ba078ea5b8cc52e7ff4c717651e9d9dba0bd615 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 14 Jul 2026 14:55:48 +0200 Subject: [PATCH] build: support PySide6 6.11.1, pyqtgraph 0.14 and PySide6-QtAds 5.0.0, remove force x11 on Linux --- bec_widgets/applications/__init__.py | 8 - bec_widgets/utils/bec_widget.py | 2 +- bec_widgets/utils/crosshair.py | 18 +- bec_widgets/utils/forms_from_types/styles.py | 13 +- .../containers/dock_area/basic_dock_area.py | 9 + .../editors/bec_console/bec_console.py | 7 +- bec_widgets/widgets/plots/heatmap/heatmap.py | 34 +++- .../widgets/utility/spinner/spinner.py | 4 +- pyproject.toml | 8 +- .../SpinnerWidget_started_darwin.png | Bin 14819 -> 14518 bytes tests/unit_tests/conftest.py | 4 + tests/unit_tests/test_crosshair.py | 174 +++++++++--------- tests/unit_tests/test_device_manager_view.py | 1 + tests/unit_tests/test_heatmap_widget.py | 22 +++ tests/unit_tests/test_image_view_next_gen.py | 6 + tests/unit_tests/test_widget_io.py | 2 +- 16 files changed, 199 insertions(+), 113 deletions(-) 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 85c5a24417cb0f5d767a098fd3595c2e81aee7bf..5142308142087bbccada7872327ce9b0ee13299e 100644 GIT binary patch literal 14518 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 Ee+tc+w*UYD literal 14819 zcmW+-Wk6I-7rwi|(kb1b2+|=fA<_yGN;gP1(zT>?cegK%bccX+cO%{1`CUK$F*Enf zoPN%k*{y}GLW~`=np|SYm2Tr9=E#@FK29@>qmFK2F@T z*QC6N46c7;kg#Lm`W&XY{KM|!m#$`PCg!mgv8%)EyYGn{TszM^0InqPQo0N;)hUQu zb1FgGK(q9}!WRop11WC5T2!oyV;A~ zKZ~r&BHsPA9kOZ>jptO9EO%bpe|yD6cLQER`LB{7-?1QJ4-WA+rlD$A!5VkLT8;0^ z=9xw2Uhlv78(d;-SL3bgxC(H)Ws$bD6)o#hj%GG+!mnuH(Z~z?ljEPe#xD)#+-^JE z*8MQ;KXYo>O>!4VVt&bmHRw=c zlqGZb(xSpx9Rjt|S(WW6Z>xElnd#n?`G<#&0vasJ4N8tdA%pr9b}cXK%;twOpq~s9 zX51jlyppx7MnhN@W8-O5Qo1`?cq+WqqEyh~*^*Mw5uZ~uUTjRNr9Q6Q*$Dep_WG-! z%FCIlAV+TdB_6{=9-FJhlOKceS~M-mkV;;?EOEkG%{SrdoBETg1Ze(unGEo4W{* zkJ;RuLsb$EN{EiVa}r94sPbOA<|@vNwzfgubSHHjO^zM?nlfL6{N~KPBtj9#fsmVJ z9w|Aj5V5gw>u6ga{Abe>WJ6+b@pN>fe{}PAZDekjYE@464pa{po zm1Xkrgq$4Zt~|-1Jipq(LTH;hbsEYlgGSZYcQw*NRb3t?+Jd!??v7vAv7`fzd<+)Z z=U8-lr7#lj{MDK@ShVN}%XkP&dFH1U_MN_4 z?yVSZtD&X$o{PbqE%w=`oo|TxP0SX4$Lp9a_{RUaJG6S#AXnKOB;Fq8)51DXUX?;r zz(=&b@ofAkR3rVaW!B~VWfe zO7=**EC!ZbKW;KnB~e*fd1_cnbJ*28%>M?|i?Yqofu zuHt2%n3VKA%HhD&x!=@vwN=7RtlE6)r@^w)<(blY4ffE8#yu=Nd990M7%6s zjoTEPX@ZIeb=dqEaS&+z!CXYjb*r08>+Sfx@xUPs6SLicsmY!G5$ca#$_;Dv!lm4& zy9^ieU#IAk2Ayv~y z;0nE*rh4h&G*e8pd>L~ywONX9g}Jw$!PUWk%)n6}!zQ2*g0gy+8<*53wsaZ8;cUh< z^;L>$BaM4m$hBmLAcrSPdO^KB!gxES2RRODyc_2-obbCh)TMI0d8x*4-(}l)bR~h)Ly>Y3qAh#}|E;o*sH&Bc7vIMA;WxV^;7n6DY z@x94(Qs68QV8yP%G`QN|YfT&?EPKV7m!x#4#n&ffjp1jxF(x9|Cl|gnwKrpxAp-?eZ&E7fd~#dRJfWz8pQ=AT|CHRi~|L?tCiue8_Pj z#gk~dDYelw#`E@jr@bJ=if1Ndv0XWY`b_D z$7Hu1s5shg=fqgvbnmVo9Lv9WD~=EXN{7IrHe}(6Hp8WA4ykj`M~7g4 zS2ym|nEsFGN4#2>GZx1gt#d!jhPoeeAJSa?J=gnui?MxZso{QgUQP9nY-_)GOSUw4 zKT~dM2~DNH3vo#6Mab_ergGWt)z!|p2kZv%x8WT3v z!-U&;W~lGS>`H1u5i-YBc140$j@N04$J5dx8JV`hTG5-U8hRAgL*Og|VCBj0dVxMw z`dK~IX$xznoczG!+Gu+;yLw#o^x@p_nrI}Mjb*5Xp+(omaRTFh@YK>5xD^M8rgFX> zVP~)>mh=e?QYasKJmWhrLNM%6{;Wk#j%0A_JW^lpJ1i<}|1>$hL;Po8G2wz;-b1+0 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 Up{Sh9I{^HXk&qWJ6VvnkA4(fshyVZp 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 ()": {},