diff --git a/tests/conftest.py b/tests/conftest.py index 8aaa540..bca072c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -176,6 +176,29 @@ def _clear_orphan_undo_stacks(): pass +def _dispose_new_views(before): + """Delete, with Qt, every parentless `DiagramView` this test built. + + A shown canvas left to `gc.collect()` has its scene, items and custom + cursors torn down in whatever order the collector reaches them, which + segfaulted CI in the hygiene's own collection. Closing it and draining + its `DeferredDelete` lets Qt free it in its own (safe) order. Views that + existed before the test (module- or class-scoped fixtures) are left + alone, and so are views inside a window (`_reap` deletes those); a view + a fixture already deleted is skipped.""" + from shiboken6 import isValid + mine = [v for v in list(_views) if v not in before] + for view in mine: + try: + if isValid(view) and view.parent() is None: + view.close() + view.deleteLater() + except RuntimeError: # the C++ view is already gone + pass + if mine: + QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + def _reap(): """Delete stale top-level windows and force their pending deletion. @@ -325,6 +348,7 @@ def _test_hygiene(request): # Qt window reaping is only meaningful for tests that can create windows; skipping it for # engine tests removes a `processEvents()` round-trip from ~1,000 tests. is_gui = request.node.get_closest_marker("gui") is not None + views_before: weakref.WeakSet[DiagramView] = weakref.WeakSet(_views) if is_gui: _reap() # Watchdog: dump all thread stacks if a test runs past its timeout. A @@ -347,6 +371,7 @@ def _test_hygiene(request): if is_gui: ThemeManager.instance()._callbacks.clear() _clear_orphan_undo_stacks() + _dispose_new_views(views_before) _reap() # Collect now, on the thread that owns Qt. A reference cycle left # holding Qt objects is otherwise freed by whichever thread next diff --git a/tests/unit/test_start_to_end_and_units.py b/tests/unit/test_start_to_end_and_units.py index cf6502a..68de429 100644 --- a/tests/unit/test_start_to_end_and_units.py +++ b/tests/unit/test_start_to_end_and_units.py @@ -178,7 +178,8 @@ class TestDistroEditorUnitConversion: the SHOWN unit, like every parameter field in the same form.""" @pytest.fixture(scope="module") - def qapp(self): + @classmethod + def qapp(cls): import os os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PySide6.QtWidgets import QApplication diff --git a/tests/unit/test_view_disposal_hygiene.py b/tests/unit/test_view_disposal_hygiene.py new file mode 100644 index 0000000..fc3bc11 --- /dev/null +++ b/tests/unit/test_view_disposal_hygiene.py @@ -0,0 +1,60 @@ +"""The per-test hygiene deletes the canvases a test built, with Qt. + +A shown `DiagramView` left to the cyclic collector segfaulted CI inside +`gc.collect()`; `_dispose_new_views` (tests/conftest.py) frees it in Qt's +own order instead. +""" +from __future__ import annotations + +import weakref + +from PySide6.QtCore import QEvent +from PySide6.QtWidgets import QApplication, QWidget +from shiboken6 import isValid + +from raptor.gui.ui.diagram import DiagramView +from tests.conftest import _dispose_new_views, _views + + +def _app(): + return QApplication.instance() or QApplication([]) + + +def test_a_view_built_after_the_snapshot_is_deleted(): + _app() + before = weakref.WeakSet(_views) + v = DiagramView() + v.show() + _dispose_new_views(before) + assert not isValid(v) + + +def test_a_view_that_existed_before_is_left_alone(): + _app() + v = DiagramView() + try: + _dispose_new_views(weakref.WeakSet(_views)) # v is in the snapshot + assert isValid(v) + finally: + v.deleteLater() + + +def test_a_view_inside_a_window_is_left_to_the_window(): + _app() + before = weakref.WeakSet(_views) + host = QWidget() + v = DiagramView(host) + try: + _dispose_new_views(before) + assert isValid(v) + finally: + host.deleteLater() + + +def test_a_view_a_fixture_already_deleted_is_skipped(): + _app() + before = weakref.WeakSet(_views) + v = DiagramView() + v.deleteLater() + QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + _dispose_new_views(before) # must not raise