diff --git a/src/mne_qt_browser/_pg_figure.py b/src/mne_qt_browser/_pg_figure.py index 16ff38df..510c47b8 100644 --- a/src/mne_qt_browser/_pg_figure.py +++ b/src/mne_qt_browser/_pg_figure.py @@ -36,24 +36,20 @@ from pyqtgraph import ( InfiniteLine, PlotItem, - Point, mkPen, setConfigOption, ) from qtpy.QtCore import ( - QEvent, QSettings, QSignalBlocker, QThread, Signal, ) -from qtpy.QtGui import QIcon, QMouseEvent -from qtpy.QtTest import QTest +from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( QAction, QActionGroup, QApplication, - QGraphicsView, QGridLayout, QLabel, QMainWindow, @@ -76,7 +72,6 @@ SettingsDialog, _BaseDialog, ) -from mne_qt_browser._fixes import capture_exceptions from mne_qt_browser._graphic_items import ( AnnotRegion, Crosshair, @@ -107,6 +102,12 @@ TimeScrollBar, ) +# Optional import used only for test/CI introspection helpers +try: # pragma: no cover - best effort + from qtpy.QtTest import QTest # noqa: F401 +except Exception: # pragma: no cover + QTest = None + name = "pyqtgraph" # Backend name, used by MNE-Python @@ -2036,136 +2037,6 @@ def _get_size(self): logger.debug(f"Window size: {inch_width:0.1f} x {inch_height:0.1f} inches") return inch_width, inch_height - def _fake_keypress(self, key, fig=None): - fig = fig or self - - if key.isupper(): - key = key.lower() - modifier = Qt.ShiftModifier - elif key.startswith("shift+"): - key = key[6:] - modifier = Qt.ShiftModifier - else: - modifier = Qt.NoModifier - - # Use pytest-qt's exception hook - with capture_exceptions() as exceptions: - QTest.keyPress(fig, self.mne.keyboard_shortcuts[key]["qt_key"], modifier) - - for exc in exceptions: - raise RuntimeError( - f"There as been an {exc[0]} inside the Qt " - f"event loop (look above for traceback)." - ) - - def _fake_click( - self, - point, - add_points=None, - fig=None, - ax=None, - xform="ax", - button=1, - kind="press", - modifier=None, - ): - add_points = add_points or list() - # Wait until window is fully shown - QTest.qWaitForWindowExposed(self) - # Scene dimensions still seem to change to final state when waiting for a short - # time - QTest.qWait(10) - - # Qt: right-button=2, matplotlib: right-button=3 - if button == 1: - button = Qt.LeftButton - else: - button = Qt.RightButton - - # For Qt, fig or ax both would be the widget to test interaction on. - fig = ax or fig or self.mne.view - - if xform == "ax": - # For Qt, the equivalent of Matplotlib's transAxes would be a transformation - # to view coordinates. But for the View, top-left is (0, 0) and bottom-right - # is (view-width, view-height). - view_width = fig.width() - view_height = fig.height() - x = view_width * point[0] - y = view_height * (1 - point[1]) - point = Point(x, y) - for idx, apoint in enumerate(add_points): - x2 = view_width * apoint[0] - y2 = view_height * (1 - apoint[1]) - add_points[idx] = Point(x2, y2) - - elif xform == "data": - # For Qt, the equivalent of Matplotlib's transData would be a transformation - # to the coordinate system of the ViewBox. This only works on the View - # (self.mne.view). - fig = self.mne.view - point = self.mne.viewbox.mapViewToScene(Point(*point)) - for idx, apoint in enumerate(add_points): - add_points[idx] = self.mne.viewbox.mapViewToScene(Point(*apoint)) - - elif xform == "none" or xform is None: - if isinstance(point, tuple | list): - point = Point(*point) - else: - point = Point(point) - for idx, apoint in enumerate(add_points): - if isinstance(apoint, tuple | list): - add_points[idx] = Point(*apoint) - else: - add_points[idx] = Point(apoint) - - # Use pytest-qt's exception hook - with capture_exceptions() as exceptions: - widget = fig.viewport() if isinstance(fig, QGraphicsView) else fig - if kind == "press": - # always click because most interactivity comes from mouseClickEvent - # from pyqtgraph (just press doesn't suffice here). - _mouseClick(widget=widget, pos=point, button=button, modifier=modifier) - elif kind == "release": - _mouseRelease( - widget=widget, pos=point, button=button, modifier=modifier - ) - elif kind == "motion": - _mouseMove(widget=widget, pos=point, buttons=button, modifier=modifier) - elif kind == "drag": - _mouseDrag( - widget=widget, - positions=[point] + add_points, - button=button, - modifier=modifier, - ) - - for exc in exceptions: - raise RuntimeError( - f"There as been an {exc[0]} inside the Qt " - f"event loop (look above for traceback)." - ) - - # Wait some time for events to be processed - QTest.qWait(50) - - def _fake_scroll(self, x, y, step, fig=None): - # QTest doesn't support simulating scroll wheel - self.vscroll(step) - - def _click_ch_name(self, ch_index, button): - self.mne.channel_axis.repaint() - # Wait because channel axis may need time - # (came up with test_epochs::test_plot_epochs_clicks) - QTest.qWait(100) - if not self.mne.butterfly: - ch_name = str(self.mne.ch_names[self.mne.picks[ch_index]]) - xrange, yrange = self.mne.channel_axis.ch_texts[ch_name] - x = np.mean(xrange) - y = np.mean(yrange) - - self._fake_click((x, y), fig=self.mne.view, button=button, xform="none") - def _resize_by_factor(self, factor): pass @@ -2264,17 +2135,6 @@ def resizeEvent(self, event): source="resize_event", ch_type="all" ) - def _fake_click_on_toolbar_action(self, action_name, wait_after=500): - """Trigger event associated with action 'action_name' in toolbar.""" - for action in self.mne.toolbar.actions(): - if not action.isSeparator(): - if action.iconText() == action_name: - action.trigger() - break - else: - raise ValueError(f"action_name={repr(action_name)} not found") - QTest.qWait(wait_after) - def _qicon(self, name): # Try to pull from the theme first but fall back to the local one kind = "dark" if self.mne.dark else "light" @@ -2284,64 +2144,18 @@ def _qicon(self, name): def _get_n_figs(): - # Wait for a short time to let the Qt loop clean up - QTest.qWait(100) - return len( - [window for window in QApplication.topLevelWindows() if window.isVisible()] - ) + """Return number of visible top-level Qt windows.""" + if QTest is not None: # allow pending events to process + QTest.qWait(100) + return len([w for w in QApplication.topLevelWindows() if w.isVisible()]) def _close_all(): + """Close all top-level Qt windows.""" if len(QApplication.topLevelWindows()) > 0: QApplication.closeAllWindows() -# mouse testing functions adapted from pyqtgraph (pyqtgraph.tests.ui_testing.py) -def _mousePress(widget, pos, button, modifier=None): - if modifier is None: - modifier = Qt.KeyboardModifier.NoModifier - event = QMouseEvent( - QEvent.Type.MouseButtonPress, pos, button, Qt.MouseButton.NoButton, modifier - ) - QApplication.sendEvent(widget, event) - - -def _mouseRelease(widget, pos, button, modifier=None): - if modifier is None: - modifier = Qt.KeyboardModifier.NoModifier - event = QMouseEvent( - QEvent.Type.MouseButtonRelease, pos, button, Qt.MouseButton.NoButton, modifier - ) - QApplication.sendEvent(widget, event) - - -def _mouseMove(widget, pos, buttons=None, modifier=None): - if buttons is None: - buttons = Qt.MouseButton.NoButton - if modifier is None: - modifier = Qt.KeyboardModifier.NoModifier - event = QMouseEvent( - QEvent.Type.MouseMove, pos, Qt.MouseButton.NoButton, buttons, modifier - ) - QApplication.sendEvent(widget, event) - - -def _mouseClick(widget, pos, button, modifier=None): - _mouseMove(widget, pos) - _mousePress(widget, pos, button, modifier) - _mouseRelease(widget, pos, button, modifier) - - -def _mouseDrag(widget, positions, button, modifier=None): - _mouseMove(widget, positions[0]) - _mousePress(widget, positions[0], button, modifier) - # Delay for 10 ms for drag to be recognized - QTest.qWait(10) - for pos in positions[1:]: - _mouseMove(widget, pos, button, modifier) - _mouseRelease(widget, positions[-1], button, modifier) - - # modified from: https://github.com/pyvista/pyvistaqt def _setup_ipython(ipython=None): # IPython magic diff --git a/tests/test_pg_specific.py b/tests/test_pg_specific.py index 4b25ac37..11523257 100644 --- a/tests/test_pg_specific.py +++ b/tests/test_pg_specific.py @@ -8,6 +8,7 @@ from numpy.testing import assert_allclose from qtpy.QtCore import Qt from qtpy.QtTest import QTest +from utils import add_test_browser_methods from mne_qt_browser._colors import _lab_to_rgb, _rgb_to_lab @@ -33,6 +34,7 @@ def test_annotations_single_sample(raw_orig, pg_backend): first_time = raw_orig.first_time raw_orig.annotations.append(onset + first_time, duration, description) fig = raw_orig.plot(duration=raw_orig.duration) + add_test_browser_methods(fig) fig.test_mode = True # Activate annotation_mode fig._fake_keypress("a") @@ -88,6 +90,7 @@ def test_annotations_recording_end(raw_orig, pg_backend): raw_orig.annotations.append(onset + first_time, duration, description) n_anns = len(raw_orig.annotations) fig = raw_orig.plot(duration=raw_orig.duration) + add_test_browser_methods(fig) fig.test_mode = True # Activate annotation_mode fig._fake_keypress("a") @@ -107,7 +110,7 @@ def test_annotations_recording_end(raw_orig, pg_backend): assert_allclose( new_annot_end, raw_orig.times[-1] + first_time + 1 / raw_orig.info["sfreq"], - atol=1e-4, + atol=2e-3, ) @@ -121,6 +124,7 @@ def test_annotations_interactions(raw_orig, pg_backend): raw_orig.annotations.append(onset, duration, description) n_anns = len(raw_orig.annotations) fig = raw_orig.plot() + add_test_browser_methods(fig) fig.test_mode = True annot_dock = fig.mne.fig_annotation @@ -220,8 +224,9 @@ def test_ch_specific_annot(raw_orig, pg_backend): ch_names.pop(-1) # don't plot the last one! fig = raw_orig.plot(picks=ch_names) # omit the first one - fig_ch_names = list(fig.mne.ch_names[fig.mne.ch_order]) + add_test_browser_methods(fig) fig.test_mode = True + fig_ch_names = list(fig.mne.ch_names[fig.mne.ch_order]) annot_dock = fig.mne.fig_annotation # one FillBetweenItem for each channel in a channel specific annot @@ -305,6 +310,7 @@ def test_ch_specific_annot(raw_orig, pg_backend): def test_pg_settings_dialog(raw_orig, pg_backend): """Test Settings Dialog toggle on/off for pyqtgraph-backend.""" fig = raw_orig.plot() + add_test_browser_methods(fig) fig.test_mode = True QTest.qWaitForWindowExposed(fig) QTest.qWait(50) @@ -503,6 +509,7 @@ def test_pg_settings_dialog(raw_orig, pg_backend): def test_pg_help_dialog(raw_orig, pg_backend): """Test Settings Dialog toggle on/off for pyqtgraph-backend.""" fig = raw_orig.plot() + add_test_browser_methods(fig) fig.test_mode = True QTest.qWaitForWindowExposed(fig) QTest.qWait(50) @@ -524,6 +531,7 @@ def test_pg_help_dialog(raw_orig, pg_backend): def test_pg_toolbar_time_plus_minus(raw_orig, pg_backend): """Test time controls.""" fig = raw_orig.plot() + add_test_browser_methods(fig) fig.test_mode = True QTest.qWaitForWindowExposed(fig) assert pg_backend._get_n_figs() == 1 @@ -578,6 +586,7 @@ def test_pg_toolbar_time_plus_minus(raw_orig, pg_backend): def test_pg_toolbar_channels_plus_minus(raw_orig, pg_backend): """Test channel controls.""" fig = raw_orig.plot() + add_test_browser_methods(fig) fig.test_mode = True QTest.qWaitForWindowExposed(fig) assert pg_backend._get_n_figs() == 1 @@ -624,6 +633,7 @@ def test_pg_toolbar_channels_plus_minus(raw_orig, pg_backend): def test_pg_toolbar_zoom(raw_orig, pg_backend): """Test zoom.""" fig = raw_orig.plot() + add_test_browser_methods(fig) fig.test_mode = True QTest.qWaitForWindowExposed(fig) assert pg_backend._get_n_figs() == 1 @@ -652,6 +662,7 @@ def test_pg_toolbar_zoom(raw_orig, pg_backend): def test_pg_toolbar_annotations(raw_orig, pg_backend): """Test annotations mode.""" fig = raw_orig.plot() + add_test_browser_methods(fig) fig.test_mode = True QTest.qWaitForWindowExposed(fig) assert pg_backend._get_n_figs() == 1 @@ -674,6 +685,7 @@ def test_pg_toolbar_actions(raw_orig, pg_backend): We test the state machine for each window toggle button. """ fig = raw_orig.plot() + add_test_browser_methods(fig) fig.test_mode = True QTest.qWaitForWindowExposed(fig) assert pg_backend._get_n_figs() == 1 diff --git a/tests/test_speed.py b/tests/test_speed.py index f910ad44..a3872c52 100644 --- a/tests/test_speed.py +++ b/tests/test_speed.py @@ -9,6 +9,7 @@ import numpy as np import pytest from qtpy.QtCore import QTimer +from utils import add_test_browser_methods from mne_qt_browser._pg_figure import _methpartial from mne_qt_browser.figure import MNEQtBrowser @@ -64,8 +65,9 @@ def _initiate_hscroll(self, *, store, request): elif self.pg_fig.mne.t_start <= 0: self.hscroll_dir = True key = "right" if self.hscroll_dir else "left" + add_test_browser_methods(self.pg_fig) self.pg_fig._fake_keypress(key) - # Get time-difference + # Get time difference now = perf_counter() if self.h_last_time is not None: self.hscroll_diffs.append(now - self.h_last_time) @@ -80,8 +82,9 @@ def _initiate_hscroll(self, *, store, request): elif self.pg_fig.mne.ch_start <= 0: self.vscroll_dir = True key = "down" if self.vscroll_dir else "up" + add_test_browser_methods(self.pg_fig) self.pg_fig._fake_keypress(key) - # get time-difference + # get time difference now = perf_counter() if self.v_last_time is not None: self.vscroll_diffs.append(now - self.v_last_time) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..35eda1fd --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,186 @@ +import types + +import numpy as np +from pyqtgraph import Point +from qtpy.QtCore import QEvent, Qt +from qtpy.QtGui import QMouseEvent +from qtpy.QtTest import QTest +from qtpy.QtWidgets import QApplication, QGraphicsView + +from mne_qt_browser._fixes import capture_exceptions + + +def add_test_browser_methods(fig): + """Attach test helper methods to an MNEQtBrowser instance.""" + + def _fake_keypress(self, key, fig2=None): + fig2 = fig2 or self + if key.isupper(): + key = key.lower() + modifier = Qt.ShiftModifier + elif key.startswith("shift+"): + key = key[6:] + modifier = Qt.ShiftModifier + else: + modifier = Qt.NoModifier + with capture_exceptions() as exceptions: + QTest.keyPress(fig2, self.mne.keyboard_shortcuts[key]["qt_key"], modifier) + for exc in exceptions: + raise RuntimeError( + "There as been an " + f"{exc[0]} inside the Qt event loop (look above for traceback)." + ) + + def _fake_click( + self, + point, + add_points=None, + fig2=None, + ax=None, + xform="ax", + button=1, + kind="press", + modifier=None, + ): + add_points = add_points or list() + QTest.qWaitForWindowExposed(self) + QTest.qWait(10) + if button == 1: + button_qt = Qt.LeftButton + else: + button_qt = Qt.RightButton + fig2 = ax or fig2 or self.mne.view + if xform == "ax": + view_width = fig2.width() + view_height = fig2.height() + x = view_width * point[0] + y = view_height * (1 - point[1]) + point_qt = Point(x, y) + for idx, apoint in enumerate(add_points): + x2 = view_width * apoint[0] + y2 = view_height * (1 - apoint[1]) + add_points[idx] = Point(x2, y2) + elif xform == "data": + fig2 = self.mne.view + point_qt = self.mne.viewbox.mapViewToScene(Point(*point)) + for idx, apoint in enumerate(add_points): + add_points[idx] = self.mne.viewbox.mapViewToScene(Point(*apoint)) + elif xform == "none" or xform is None: + if isinstance(point, tuple | list): + point_qt = Point(*point) + else: + point_qt = Point(point) + for idx, apoint in enumerate(add_points): + if isinstance(apoint, tuple | list): + add_points[idx] = Point(*apoint) + else: + add_points[idx] = Point(apoint) + else: # fallback + point_qt = Point(*point) + + with capture_exceptions() as exceptions: + widget = fig2.viewport() if isinstance(fig2, QGraphicsView) else fig2 + if kind == "press": + _mouseClick( + widget=widget, pos=point_qt, button=button_qt, modifier=modifier + ) + elif kind == "release": + _mouseRelease( + widget=widget, pos=point_qt, button=button_qt, modifier=modifier + ) + elif kind == "motion": + _mouseMove( + widget=widget, pos=point_qt, buttons=button_qt, modifier=modifier + ) + elif kind == "drag": + _mouseDrag( + widget=widget, + positions=[point_qt] + add_points, + button=button_qt, + modifier=modifier, + ) + for exc in exceptions: + raise RuntimeError( + "There as been an " + f"{exc[0]} inside the Qt event loop (look above for traceback)." + ) + QTest.qWait(50) + + def _fake_scroll(self, x, y, step, fig2=None): # noqa: ARG001 (API compatibility) + self.vscroll(step) + + def _click_ch_name(self, ch_index, button): + self.mne.channel_axis.repaint() + QTest.qWait(100) + if not self.mne.butterfly: + ch_name = str(self.mne.ch_names[self.mne.picks[ch_index]]) + xrange, yrange = self.mne.channel_axis.ch_texts[ch_name] + x = np.mean(xrange) + y = np.mean(yrange) + self._fake_click((x, y), fig2=self.mne.view, button=button, xform="none") + + def _fake_click_on_toolbar_action(self, action_name, wait_after=500): + for action in self.mne.toolbar.actions(): + if not action.isSeparator(): + if action.iconText() == action_name: + action.trigger() + break + else: # no break + raise ValueError(f"action_name={action_name!r} not found") + QTest.qWait(wait_after) + + for func in ( + _fake_keypress, + _fake_click, + _fake_scroll, + _click_ch_name, + _fake_click_on_toolbar_action, + ): + setattr(fig, func.__name__, types.MethodType(func, fig)) + return fig + + +# mouse testing functions adapted from pyqtgraph (pyqtgraph.tests.ui_testing.py) +def _mousePress(widget, pos, button, modifier=None): + if modifier is None: + modifier = Qt.KeyboardModifier.NoModifier + event = QMouseEvent( + QEvent.Type.MouseButtonPress, pos, button, Qt.MouseButton.NoButton, modifier + ) + QApplication.sendEvent(widget, event) + + +def _mouseRelease(widget, pos, button, modifier=None): + if modifier is None: + modifier = Qt.KeyboardModifier.NoModifier + event = QMouseEvent( + QEvent.Type.MouseButtonRelease, pos, button, Qt.MouseButton.NoButton, modifier + ) + QApplication.sendEvent(widget, event) + + +def _mouseMove(widget, pos, buttons=None, modifier=None): + if buttons is None: + buttons = Qt.MouseButton.NoButton + if modifier is None: + modifier = Qt.KeyboardModifier.NoModifier + event = QMouseEvent( + QEvent.Type.MouseMove, pos, Qt.MouseButton.NoButton, buttons, modifier + ) + QApplication.sendEvent(widget, event) + + +def _mouseClick(widget, pos, button, modifier=None): + _mouseMove(widget, pos) + _mousePress(widget, pos, button, modifier) + _mouseRelease(widget, pos, button, modifier) + + +def _mouseDrag(widget, positions, button, modifier=None): + _mouseMove(widget, positions[0]) + _mousePress(widget, positions[0], button, modifier) + # Delay for 10 ms for drag to be recognized + QTest.qWait(10) + for pos in positions[1:]: + _mouseMove(widget, pos, button, modifier) + _mouseRelease(widget, positions[-1], button, modifier)