From 6cb7640cf8427dff03c3167d04f5f4e6b94afd54 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Mon, 12 Oct 2026 05:20:05 +0200 Subject: [PATCH] test: clean up Qt objects before shutdown --- tests/conftest.py | 39 ++++++++++++++++++++++++++++++++++ tests/test_assets_and_icons.py | 2 ++ tests/test_bundle_selftest.py | 8 ++----- tests/test_installer.py | 15 ++----------- tests/test_settings_dialog.py | 14 +----------- tests/test_tray_icon.py | 23 ++++++-------------- 6 files changed, 53 insertions(+), 48 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3de7f48..42bb1f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,44 @@ """Add parent directory to sys.path for imports.""" +import os import sys from pathlib import Path +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + sys.path.insert(0, str(Path(__file__).parent.parent)) + + +@pytest.fixture(scope="session") +def qapp(): + """Keep one Qt application alive and dispose test-owned Qt UI before shutdown.""" + from PySide6.QtCore import QCoreApplication, QEvent + from PySide6.QtWidgets import QApplication, QSystemTrayIcon + + app = QApplication.instance() + if app is None: + app = QApplication([]) + assert isinstance(app, QApplication) + + yield app + + tray_icons = tuple(app.findChildren(QSystemTrayIcon)) + widgets = list(app.topLevelWidgets()) + for icon in tray_icons: + menu = icon.contextMenu() + if menu is not None and menu not in widgets: + widgets.append(menu) + icon.hide() + icon.setContextMenu(None) + + for widget in widgets: + widget.close() + widget.deleteLater() + for icon in tray_icons: + icon.deleteLater() + + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + assert not app.topLevelWidgets(), "Qt top-level widgets remained after session cleanup" + assert not app.findChildren(QSystemTrayIcon), "Qt tray icons remained after session cleanup" diff --git a/tests/test_assets_and_icons.py b/tests/test_assets_and_icons.py index 5282f91..26b98e3 100644 --- a/tests/test_assets_and_icons.py +++ b/tests/test_assets_and_icons.py @@ -9,6 +9,7 @@ from pathlib import Path from PIL import Image +import pytest ROOT = Path(__file__).resolve().parent.parent if str(ROOT) not in sys.path: @@ -32,6 +33,7 @@ def get_ico_sizes(ico_path: Path) -> list[tuple[int, int]]: return sorted(sizes) +@pytest.mark.usefixtures("qapp") class TestMailProcessorIconsAndAssets(unittest.TestCase): """Testet Existenz, Dimensionen und Layer-Vollständigkeit aller Icons.""" diff --git a/tests/test_bundle_selftest.py b/tests/test_bundle_selftest.py index 13af4b6..005ede5 100644 --- a/tests/test_bundle_selftest.py +++ b/tests/test_bundle_selftest.py @@ -5,14 +5,13 @@ from pathlib import Path import pytest -from PySide6.QtWidgets import QApplication import config as cfg_module import main as main_module @pytest.fixture -def isolated_qapp(monkeypatch, tmp_path): +def isolated_qapp(qapp, monkeypatch, tmp_path): profile = tmp_path / "profile" monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") monkeypatch.setenv("LOCALAPPDATA", str(profile / "localappdata")) @@ -21,10 +20,7 @@ def isolated_qapp(monkeypatch, tmp_path): monkeypatch.setenv("TEMP", str(profile / "temp")) monkeypatch.setenv("TMP", str(profile / "temp")) - app = QApplication.instance() - if app is None: - app = QApplication([]) - return app, profile + return qapp, profile def test_self_test_checks_frozen_ui_without_loading_or_saving_profile( diff --git a/tests/test_installer.py b/tests/test_installer.py index 4b54056..66aa462 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -1,9 +1,7 @@ """Tests for installer.py - wizard state and download tracking.""" -import os - import pytest -from PySide6.QtWidgets import QApplication, QDialog, QPushButton +from PySide6.QtWidgets import QDialog, QPushButton from config import AppConfig, ToolConfig from i18n import set_language @@ -11,15 +9,6 @@ from tool_manager import ToolManager -@pytest.fixture -def qapp(): - os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - app = QApplication.instance() - if app is None: - app = QApplication([]) - return app - - @pytest.fixture def tm(monkeypatch): cfg = AppConfig() @@ -55,7 +44,7 @@ def _fake_exec(self): monkeypatch.setattr(SettingsDialog, "exec", _fake_exec) - tray = MailProcessorTray(cfg) + tray = MailProcessorTray(cfg, parent=qapp) tray._open_settings() assert cfg.tools["universal_mail_cleaner"].enabled is True, ( diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index d916d45..176b37d 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1,22 +1,10 @@ """Tests for settings_dialog.py — rescan feedback and tool management.""" -import os - -import pytest -from PySide6.QtWidgets import QApplication, QMessageBox +from PySide6.QtWidgets import QMessageBox from config import AppConfig, ToolConfig from i18n import set_language -@pytest.fixture -def qapp(): - os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - app = QApplication.instance() - if app is None: - app = QApplication([]) - return app - - def test_rescan_shows_rescan_done_when_tools_added(qapp, tmp_path, monkeypatch): """_on_rescan() shows 'rescan_done' message when new tools are detected.""" set_language("de") diff --git a/tests/test_tray_icon.py b/tests/test_tray_icon.py index 6400f0d..a527ec3 100644 --- a/tests/test_tray_icon.py +++ b/tests/test_tray_icon.py @@ -1,13 +1,10 @@ from __future__ import annotations import hashlib -import os import struct from pathlib import Path -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QMenu, QSystemTrayIcon import tray @@ -34,13 +31,11 @@ def _read_ico_sizes(path: Path) -> set[tuple[int, int]]: return sizes -def test_runtime_icon_loads_from_file_instead_of_fallback(): - app = QApplication.instance() or QApplication([]) - +def test_runtime_icon_loads_from_file_instead_of_fallback(qapp): icon = tray._load_tray_icon() fallback = tray._make_tray_icon() - assert app is not None + assert qapp is not None assert not icon.isNull() assert icon.cacheKey() != fallback.cacheKey() @@ -58,7 +53,7 @@ def test_runtime_icon_contains_expected_sizes(): assert {(16, 16), (32, 32), (48, 48), (256, 256)}.issubset(sizes) -def test_pyside6_keeps_context_menu_alive_after_gc(): +def test_pyside6_keeps_context_menu_alive_after_gc(qapp): """Dokumentiert: PySide6 hält QMenu intern am Leben nach setContextMenu(). Auch ohne Python-Instanzvariable (lokale Variable) überlebt das QMenu gc.collect(). @@ -67,10 +62,7 @@ def test_pyside6_keeps_context_menu_alive_after_gc(): """ import gc - from PySide6.QtWidgets import QMenu, QSystemTrayIcon - - _app = QApplication.instance() or QApplication([]) - icon = QSystemTrayIcon() + icon = QSystemTrayIcon(qapp) local_menu = QMenu() local_menu.addAction("MarkAction") icon.setContextMenu(local_menu) @@ -82,14 +74,13 @@ def test_pyside6_keeps_context_menu_alive_after_gc(): assert len(result.actions()) > 0, "Aktionen bleiben nach GC erhalten" -def test_build_menu_stores_menu_as_instance_variable(): +def test_build_menu_stores_menu_as_instance_variable(qapp): """_build_menu muss self._context_menu setzen damit Python-GC das QMenu nicht löscht.""" from config import AppConfig from tray import MailProcessorTray - _app = QApplication.instance() or QApplication([]) cfg = AppConfig() - tray_icon = MailProcessorTray(cfg) + tray_icon = MailProcessorTray(cfg, parent=qapp) assert hasattr(tray_icon, "_context_menu"), "_context_menu muss als Instanzvariable gehalten werden" assert tray_icon._context_menu is not None