diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f4003d6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,80 @@ +"""Shared pytest setup and teardown for ExplorerPro. + +1. Safe Symlink & Numbered Dir Cleanup (Windows): + On Windows, pytest's `cleanup_dead_symlinks` in `_pytest.pathlib` checks + `left_dir.resolve().exists()` on `pytest-current` (a directory symlink). + Without Developer Mode or when symlink permissions differ, `exists()` raises + `PermissionError: [WinError 5] Zugriff verweigert: '...\\pytest-current'`. + When raised inside `tmp_path_factory._exit_stack.close()` at sessionfinish, + CPython aborts with `Fatal Python error: Aborted` / `STATUS_STACK_BUFFER_OVERRUN` + (exit code 1). Suppressing OSError in `cleanup_dead_symlinks` and `cleanup_numbered_dir` + guards against this Windows-specific teardown failure. + +2. Clean Qt Top-Level Widget Teardown: + Closes and schedules `deleteLater()` for remaining top-level Qt widgets + and processes pending events, preventing dangling QObject destructors + at interpreter exit. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +try: + import _pytest.pathlib as _pytest_pathlib + + _orig_cleanup_dead_symlinks = _pytest_pathlib.cleanup_dead_symlinks + _orig_cleanup_numbered_dir = _pytest_pathlib.cleanup_numbered_dir + + def _safe_cleanup_dead_symlinks(root): + try: + _orig_cleanup_dead_symlinks(root) + except OSError: + pass + + def _safe_cleanup_numbered_dir(root, prefix, keep, consider_lock_dead_if_created_before): + try: + _orig_cleanup_numbered_dir(root, prefix, keep, consider_lock_dead_if_created_before) + except OSError: + pass + + _pytest_pathlib.cleanup_dead_symlinks = _safe_cleanup_dead_symlinks + _pytest_pathlib.cleanup_numbered_dir = _safe_cleanup_numbered_dir +except (ImportError, AttributeError): + pass + +import pytest +from PySide6.QtWidgets import QApplication + + +@pytest.fixture(autouse=True) +def _cleanup_qt_widgets(): + """Ensure top-level Qt widgets created during a test are closed and drained.""" + yield + app = QApplication.instance() + if app is not None: + for widget in app.topLevelWidgets(): + try: + widget.close() + widget.deleteLater() + except Exception: + pass + app.processEvents() + + +def pytest_sessionfinish(session, exitstatus): + """Drain any remaining events before interpreter shutdown.""" + app = QApplication.instance() + if app is not None: + try: + for widget in app.topLevelWidgets(): + try: + widget.close() + widget.deleteLater() + except Exception: + pass + for _ in range(5): + app.processEvents() + except Exception: + pass diff --git a/tests/test_checksum_dialog.py b/tests/test_checksum_dialog.py index d9fdb8d..ffc1523 100644 --- a/tests/test_checksum_dialog.py +++ b/tests/test_checksum_dialog.py @@ -71,10 +71,13 @@ def test_checksum_dialog_non_existent_file(tmp_path: Path): def test_checksum_dialog_close_cancels_worker(tmp_path: Path): """Prüft, dass closeEvent den Worker sauber abbricht.""" - _ensure_app() + app = _ensure_app() sample = tmp_path / "cancel_test.txt" sample.write_bytes(b"X" * (500 * 1024)) dlg = ChecksumDialog(str(sample)) dlg.closeEvent(QCloseEvent()) assert dlg.worker is not None + dlg.close() + dlg.deleteLater() + app.processEvents() diff --git a/tests/test_store_regressions_20260928.py b/tests/test_store_regressions_20260928.py index a7fe322..fe02fe0 100644 --- a/tests/test_store_regressions_20260928.py +++ b/tests/test_store_regressions_20260928.py @@ -254,3 +254,48 @@ def test_frozen_run_button_starts_interpreter_not_explorerpro(tmp_path, monkeypa editor._run_code() assert launched == [(str(interpreter), [str(script)])] editor.close() + + +def test_right_click_on_unselected_row_selects_row(tmp_path, monkeypatch): + _app() + first = tmp_path / "first.txt" + second = tmp_path / "second.txt" + first.write_text("first", encoding="utf-8") + second.write_text("second", encoding="utf-8") + + browser = FileBrowser() + browser.resize(800, 500) + browser.show() + browser.navigate_to(str(tmp_path)) + for _ in range(100): + _app().processEvents() + if browser.proxy.rowCount(browser.table.rootIndex()) == 2: + break + time.sleep(.01) + + idx_first = browser.proxy.mapFromSource(browser.model.index(str(first))) + browser.table.selectRow(idx_first.row()) + assert [Path(p) for p in browser.get_selected_files()] == [first] + + idx_second = browser.proxy.mapFromSource(browser.model.index(str(second))) + assert not browser.table.selectionModel().isRowSelected(idx_second.row(), idx_second.parent()) + + menus = [] + + class CapturingMenu(QMenu): + def exec(self, *args): + menus.append(self) + + monkeypatch.setattr(browser_module, "QMenu", CapturingMenu) + pos = browser.table.visualRect(idx_second).center() + event = QContextMenuEvent( + QContextMenuEvent.Reason.Mouse, pos, browser.table.viewport().mapToGlobal(pos) + ) + QApplication.sendEvent(browser.table.viewport(), event) + + assert browser.table.selectionModel().isRowSelected(idx_second.row(), idx_second.parent()) + assert second in [Path(p) for p in browser.get_selected_files()] + + browser.close() + browser.deleteLater() + _app().processEvents()