From 90f653386adbf5a9e5a24fb7fc9ba8e79cf78660 Mon Sep 17 00:00:00 2001 From: Dawn Date: Thu, 17 Sep 2026 21:10:58 +0200 Subject: [PATCH] fix(gui): app-level event filters tolerate PySide's stray QWidgetItem CI (3.12, PR 232) failed test_font_zoom_keeps_frozen_column_aligned_and_reautosizes with a pytest-qt CALL ERROR: PySide 6.9 handed both app-level filters a QWidgetItem instead of a QEvent during layout teardown, and event.type() raised. The wheel guard now keys on isinstance(event, QWheelEvent) and returns False itself instead of super().eventFilter() (which type-checks its arguments and would raise the same way); the cursor filter ignores anything that is not a QEvent. Regression test for each. Co-Authored-By: Claude Fable 5.1 --- src/aare/gui/main_window.py | 4 ++++ src/aare/gui/widgets/wheel_value_guard.py | 11 ++++++++--- tests/unit/gui/test_main_window.py | 11 +++++++++-- tests/unit/gui/test_wheel_value_guard.py | 18 +++++++++++++++++- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 87f0621d..67fecf58 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -150,6 +150,10 @@ class ClickableCursorFilter(QObject): forbidden cursor) still win: they set it later.""" def eventFilter(self, obj, event): + # Same PySide 6.9 quirk as WheelValueGuard: an app-level filter can be + # handed a QWidgetItem instead of a QEvent during layout teardown. + if not isinstance(event, QEvent): + return False if event.type() == QEvent.Type.Polish and isinstance( obj, (QAbstractButton, QComboBox, QSlider) ): diff --git a/src/aare/gui/widgets/wheel_value_guard.py b/src/aare/gui/widgets/wheel_value_guard.py index 7697de8e..f06eb504 100644 --- a/src/aare/gui/widgets/wheel_value_guard.py +++ b/src/aare/gui/widgets/wheel_value_guard.py @@ -1,4 +1,4 @@ -from PySide6.QtCore import QEvent, QObject, Qt +from PySide6.QtCore import QObject, Qt from PySide6.QtGui import QWheelEvent from PySide6.QtWidgets import ( QAbstractScrollArea, @@ -26,7 +26,10 @@ class WheelValueGuard(QObject): GUARDED = (QAbstractSpinBox, QSlider, QDial, QComboBox, QTabBar) def eventFilter(self, obj, event): - if event.type() == QEvent.Type.Wheel and isinstance(obj, self.GUARDED): + # isinstance, not event.type(): PySide 6.9 sometimes hands an app-level + # filter a QWidgetItem instead of a QEvent while layouts are torn down, + # and .type() then raises (pytest-qt CALL ERROR on a random test). + if isinstance(event, QWheelEvent) and isinstance(obj, self.GUARDED): if event.buttons() & Qt.MouseButton.RightButton: return False # right button held: deliberate value adjustment area = obj.parentWidget() @@ -45,7 +48,9 @@ class WheelValueGuard(QObject): ) QApplication.sendEvent(area.viewport(), relayed) return True - return super().eventFilter(obj, event) + # Not super().eventFilter(): QObject's does nothing but type-checks its + # arguments, so the stray QWidgetItem would raise there instead. + return False if __name__ == "__main__": diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index c7f2e3ab..c6cba4c0 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -3,10 +3,10 @@ from unittest.mock import MagicMock, patch import pytest from PySide6.QtCore import QSettings, Qt -from PySide6.QtWidgets import QApplication, QDockWidget +from PySide6.QtWidgets import QApplication, QDockWidget, QWidget, QWidgetItem from aare.gui import styles -from aare.gui.main_window import MainWindow +from aare.gui.main_window import ClickableCursorFilter, MainWindow from aare.gui.styles import THEME_BLUEBIRD, THEME_SUNRISE, THEME_SUNSET @@ -683,3 +683,10 @@ def test_sample_camera_frame_paints_visible_views_and_acks(qtbot, mock_ui_state, # No subscriber (GUI started without a sample feed): the slot must not blow up. win.prediction_thread = None win._on_sample_camera_frame(QImage(4, 6, QImage.Format.Format_RGB888)) + + +def test_cursor_filter_ignores_a_non_event_argument(qapp): + # PySide 6.9 can hand an app-level filter a QWidgetItem instead of a + # QEvent during layout teardown; raising there fails a random test. + widget = QWidget() + assert ClickableCursorFilter().eventFilter(widget, QWidgetItem(widget)) is False diff --git a/tests/unit/gui/test_wheel_value_guard.py b/tests/unit/gui/test_wheel_value_guard.py index 68d760a9..30ae245c 100644 --- a/tests/unit/gui/test_wheel_value_guard.py +++ b/tests/unit/gui/test_wheel_value_guard.py @@ -5,7 +5,15 @@ deliberate right-button + wheel gesture.""" import pytest from PySide6.QtCore import QPoint, QPointF, Qt from PySide6.QtGui import QWheelEvent -from PySide6.QtWidgets import QApplication, QScrollArea, QSlider, QSpinBox, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QApplication, + QScrollArea, + QSlider, + QSpinBox, + QVBoxLayout, + QWidget, + QWidgetItem, +) from aare.gui.widgets.wheel_value_guard import WheelValueGuard @@ -72,3 +80,11 @@ def test_bare_wheel_scrolls_the_enclosing_area(guard, qtbot): QApplication.sendEvent(spin, _wheel(Qt.MouseButton.NoButton)) assert spin.value() == 50 assert bar.value() != before + + +def test_a_non_event_argument_is_ignored(guard, qtbot): + # PySide 6.9 can hand an app-level filter a QWidgetItem instead of a + # QEvent during layout teardown; raising there fails a random test. + widget = QWidget() + qtbot.addWidget(widget) + assert guard.eventFilter(widget, QWidgetItem(widget)) is False