253 lines
8.2 KiB
Python
253 lines
8.2 KiB
Python
|
|
from collections.abc import Iterator
|
||
|
|
from pathlib import Path
|
||
|
|
import shutil
|
||
|
|
from types import SimpleNamespace
|
||
|
|
|
||
|
|
from PyQt6.QtCore import (
|
||
|
|
QCoreApplication,
|
||
|
|
QEvent,
|
||
|
|
QSettings,
|
||
|
|
)
|
||
|
|
from PyQt6.QtWidgets import (
|
||
|
|
QApplication,
|
||
|
|
QWidget,
|
||
|
|
)
|
||
|
|
import pyqtgraph as pg
|
||
|
|
from pyqtgraph import ViewBox
|
||
|
|
import pytest
|
||
|
|
from pytestqt.qtbot import QtBot
|
||
|
|
|
||
|
|
from piker import config
|
||
|
|
|
||
|
|
|
||
|
|
_PREEXISTING_SETTING: str = 'pytest/preexisting'
|
||
|
|
_OWNED_SETTING: str = 'pytest/owned'
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope='session', autouse=True)
|
||
|
|
def ui_process_sentinel(
|
||
|
|
isolated_xdg_config_home: Path,
|
||
|
|
|
||
|
|
) -> Iterator[Path]:
|
||
|
|
'''
|
||
|
|
Keep known process-owned state across all UI tests.
|
||
|
|
|
||
|
|
'''
|
||
|
|
# TODO: expose public config-dir get/set APIs like
|
||
|
|
# `modden.config.dirs.get_conf_dir()` and `set_conf_dir()` so
|
||
|
|
# tests do not reach into Piker's import-cached path globals.
|
||
|
|
config_dir: Path = config._click_config_dir
|
||
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
sentinel_path: Path = config_dir / 'pytest-preexisting'
|
||
|
|
sentinel_existed: bool = sentinel_path.exists()
|
||
|
|
sentinel_bytes: bytes|None = None
|
||
|
|
if sentinel_existed:
|
||
|
|
sentinel_bytes = sentinel_path.read_bytes()
|
||
|
|
sentinel_path.write_text('keep-me', encoding='utf-8')
|
||
|
|
|
||
|
|
settings: QSettings = QSettings('pikers', 'piker')
|
||
|
|
settings.setFallbacksEnabled(False)
|
||
|
|
setting_existed: bool = settings.contains(
|
||
|
|
_PREEXISTING_SETTING,
|
||
|
|
)
|
||
|
|
setting_value: object = settings.value(
|
||
|
|
_PREEXISTING_SETTING,
|
||
|
|
)
|
||
|
|
settings.setValue(_PREEXISTING_SETTING, 'keep-me')
|
||
|
|
settings.sync()
|
||
|
|
|
||
|
|
try:
|
||
|
|
assert config_dir.is_relative_to(isolated_xdg_config_home)
|
||
|
|
yield sentinel_path
|
||
|
|
finally:
|
||
|
|
if setting_existed:
|
||
|
|
settings.setValue(
|
||
|
|
_PREEXISTING_SETTING,
|
||
|
|
setting_value,
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
settings.remove(_PREEXISTING_SETTING)
|
||
|
|
settings.sync()
|
||
|
|
|
||
|
|
if sentinel_existed:
|
||
|
|
assert sentinel_bytes is not None
|
||
|
|
sentinel_path.write_bytes(sentinel_bytes)
|
||
|
|
else:
|
||
|
|
sentinel_path.unlink(missing_ok=True)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def isolated_ui_state(
|
||
|
|
qapp: QApplication,
|
||
|
|
qtbot: QtBot,
|
||
|
|
tmp_path: Path,
|
||
|
|
isolated_xdg_config_home: Path,
|
||
|
|
ui_process_sentinel: Path,
|
||
|
|
|
||
|
|
) -> Iterator[SimpleNamespace]:
|
||
|
|
'''
|
||
|
|
Restore mutable process state after one real Qt test.
|
||
|
|
|
||
|
|
Pytest-qt owns the shared `QApplication` and closes widgets
|
||
|
|
before this fixture unwinds. This guard snapshots the remaining
|
||
|
|
process globals, removes exact test-owned config, and reports any
|
||
|
|
widget or PyQtGraph object that survived normal pytest-qt
|
||
|
|
cleanup.
|
||
|
|
|
||
|
|
'''
|
||
|
|
top_levels: set[QWidget] = set(
|
||
|
|
qapp.topLevelWidgets()
|
||
|
|
)
|
||
|
|
all_views: set[ViewBox] = set(ViewBox.AllViews)
|
||
|
|
named_views: dict[str, ViewBox] = dict(ViewBox.NamedViews)
|
||
|
|
pg_options: dict[str, object] = dict(pg.CONFIG_OPTIONS)
|
||
|
|
quit_on_last_window: bool = (
|
||
|
|
qapp.quitOnLastWindowClosed()
|
||
|
|
)
|
||
|
|
config_paths: dict[str, Path] = {
|
||
|
|
'_click_config_dir': config._click_config_dir,
|
||
|
|
'_config_dir': config._config_dir,
|
||
|
|
'_watchlists_data_path': config._watchlists_data_path,
|
||
|
|
}
|
||
|
|
settings: QSettings = QSettings('pikers', 'piker')
|
||
|
|
settings.setFallbacksEnabled(False)
|
||
|
|
settings_state: dict[str, object] = {
|
||
|
|
key: settings.value(key)
|
||
|
|
for key in settings.allKeys()
|
||
|
|
}
|
||
|
|
test_config_dir: Path = tmp_path / 'piker-config'
|
||
|
|
test_config_dir.mkdir()
|
||
|
|
state: SimpleNamespace = SimpleNamespace(
|
||
|
|
qapp=qapp,
|
||
|
|
process_config_home=isolated_xdg_config_home,
|
||
|
|
preexisting_config=ui_process_sentinel,
|
||
|
|
test_config_dir=test_config_dir,
|
||
|
|
preexisting_setting=_PREEXISTING_SETTING,
|
||
|
|
owned_setting=_OWNED_SETTING,
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
yield state
|
||
|
|
finally:
|
||
|
|
# Collect every isolation failure so cleanup of one process
|
||
|
|
# global cannot prevent restoration of the remaining globals.
|
||
|
|
errors: list[Exception] = []
|
||
|
|
|
||
|
|
# Pytest-qt closes registered widgets before fixture
|
||
|
|
# teardown, but Qt completes `QWidget.deleteLater()`
|
||
|
|
# asynchronously.
|
||
|
|
QCoreApplication.sendPostedEvents(
|
||
|
|
None,
|
||
|
|
QEvent.Type.DeferredDelete,
|
||
|
|
)
|
||
|
|
qapp.processEvents()
|
||
|
|
|
||
|
|
# Any addition to `QApplication.topLevelWidgets()` after the
|
||
|
|
# deferred-delete drain is a widget pytest-qt failed to own.
|
||
|
|
leaked_widgets: set[QWidget] = (
|
||
|
|
set(qapp.topLevelWidgets()) - top_levels
|
||
|
|
)
|
||
|
|
for widget in leaked_widgets:
|
||
|
|
widget.close()
|
||
|
|
widget.deleteLater()
|
||
|
|
QCoreApplication.sendPostedEvents(
|
||
|
|
None,
|
||
|
|
QEvent.Type.DeferredDelete,
|
||
|
|
)
|
||
|
|
qapp.processEvents()
|
||
|
|
if leaked_widgets:
|
||
|
|
errors.append(AssertionError(
|
||
|
|
'pytest-qt left test-owned top-level widgets alive: '
|
||
|
|
f'{leaked_widgets!r}'
|
||
|
|
))
|
||
|
|
|
||
|
|
# `ViewBox.AllViews` and `ViewBox.NamedViews` are
|
||
|
|
# process-wide weak registries used for linked-axis
|
||
|
|
# discovery.
|
||
|
|
# Close leaked views, report either registry delta, then
|
||
|
|
# restore the exact baseline so a failure cannot contaminate
|
||
|
|
# the next test.
|
||
|
|
leaked_views: set[ViewBox] = (
|
||
|
|
set(ViewBox.AllViews) - all_views
|
||
|
|
)
|
||
|
|
for view in leaked_views:
|
||
|
|
view.close()
|
||
|
|
if leaked_views:
|
||
|
|
errors.append(AssertionError(
|
||
|
|
'test-owned `ViewBox.AllViews` entries survived: '
|
||
|
|
f'{leaked_views!r}'
|
||
|
|
))
|
||
|
|
named_views_changed: bool = (
|
||
|
|
dict(ViewBox.NamedViews) != named_views
|
||
|
|
)
|
||
|
|
if named_views_changed:
|
||
|
|
errors.append(AssertionError(
|
||
|
|
'test mutated `ViewBox.NamedViews` without cleanup'
|
||
|
|
))
|
||
|
|
ViewBox.AllViews.clear()
|
||
|
|
ViewBox.AllViews.update({
|
||
|
|
view: None
|
||
|
|
for view in all_views
|
||
|
|
})
|
||
|
|
ViewBox.NamedViews.clear()
|
||
|
|
ViewBox.NamedViews.update(named_views)
|
||
|
|
ViewBox.updateAllViewLists()
|
||
|
|
|
||
|
|
# PyQtGraph options and Qt's last-window policy are mutable
|
||
|
|
# process globals, not properties owned by one test widget.
|
||
|
|
pg.CONFIG_OPTIONS.clear()
|
||
|
|
pg.CONFIG_OPTIONS.update(pg_options)
|
||
|
|
qapp.setQuitOnLastWindowClosed(quit_on_last_window)
|
||
|
|
|
||
|
|
# Rebuild this test application's `QSettings` keys exactly:
|
||
|
|
# test-owned keys disappear while pre-existing values
|
||
|
|
# survive.
|
||
|
|
for key in settings.allKeys():
|
||
|
|
settings.remove(key)
|
||
|
|
for key, value in settings_state.items():
|
||
|
|
settings.setValue(key, value)
|
||
|
|
settings.sync()
|
||
|
|
|
||
|
|
# Restore Piker's import-cached path globals before deleting
|
||
|
|
# the function-owned config tree to prevent a dangling path.
|
||
|
|
for name, path in config_paths.items():
|
||
|
|
setattr(config, name, path)
|
||
|
|
shutil.rmtree(test_config_dir, ignore_errors=True)
|
||
|
|
|
||
|
|
# Re-read every restored process value instead of trusting
|
||
|
|
# the cleanup assignments; each mismatch joins one report.
|
||
|
|
if dict(pg.CONFIG_OPTIONS) != pg_options:
|
||
|
|
errors.append(AssertionError(
|
||
|
|
'`pyqtgraph.CONFIG_OPTIONS` was not restored'
|
||
|
|
))
|
||
|
|
if qapp.quitOnLastWindowClosed() != quit_on_last_window:
|
||
|
|
errors.append(AssertionError(
|
||
|
|
'`QApplication.quitOnLastWindowClosed()` changed'
|
||
|
|
))
|
||
|
|
if {
|
||
|
|
name: getattr(config, name)
|
||
|
|
for name in config_paths
|
||
|
|
} != config_paths:
|
||
|
|
errors.append(AssertionError(
|
||
|
|
'Piker config path globals were not restored'
|
||
|
|
))
|
||
|
|
if {
|
||
|
|
key: settings.value(key)
|
||
|
|
for key in settings.allKeys()
|
||
|
|
} != settings_state:
|
||
|
|
errors.append(AssertionError(
|
||
|
|
'`QSettings` keys were not restored exactly'
|
||
|
|
))
|
||
|
|
if test_config_dir.exists():
|
||
|
|
errors.append(AssertionError(
|
||
|
|
'test-owned config directory survived teardown'
|
||
|
|
))
|
||
|
|
|
||
|
|
# Raise only after all restoration and verification
|
||
|
|
# completes, preserving each leak signal for diagnosis.
|
||
|
|
if errors:
|
||
|
|
raise ExceptionGroup(
|
||
|
|
'UI isolation teardown failed',
|
||
|
|
errors,
|
||
|
|
)
|