Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/test_start_to_end_and_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions tests/unit/test_view_disposal_hygiene.py
Original file line number Diff line number Diff line change
@@ -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
Loading