Skip to content

Commit a0b7549

Browse files
author
Carmelo
committed
test: add qt marker and fix headless abort via pytest-env
Root cause: QT_QPA_PLATFORM=offscreen was declared in pyproject.toml but pytest-env was not installed, so the env var was never actually set. QApplication() then aborted the process in headless CI. Fix: - Add pytest-env>=1.1.5 to dev dependencies so the existing env = ["QT_QPA_PLATFORM=offscreen"] config takes effect - Register a 'qt' marker in [tool.pytest.ini_options] - Add tests/conftest.py with pytest_collection_modifyitems that auto-skips @pytest.mark.qt tests when no display is available (headless without pytest-env or QT_QPA_PLATFORM unset) - Replace all inline @pytest.mark.skipif(sys.platform == 'linux'...) guards with the cleaner @pytest.mark.qt marker - Apply @pytest.mark.qt to TestQtAppContainer class (class-level pytestmark) and to test_presenter_is_provider / test_base_qt_view in test_view.py Result: 133 passed, no abort, no warnings
1 parent a982148 commit a0b7549

File tree

5 files changed

+101
-26
lines changed

5 files changed

+101
-26
lines changed

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ dev = [
4141
"pre-commit>=4.5.0",
4242
"pytest>=9.0.2",
4343
"pytest-cov>=7.0.0",
44+
"pytest-env>=1.1.5",
4445
"types-pyyaml>=6.0.12.20250915",
4546
"ruff>=0.14.14",
4647
"ophyd>=1.11.0",
@@ -142,6 +143,9 @@ testpaths = [
142143
"tests",
143144
]
144145
addopts = "-p no:warnings"
146+
markers = [
147+
"qt: marks tests that require a Qt display (skipped in headless environments)",
148+
]
145149
env = [
146150
"QT_QPA_PLATFORM=offscreen",
147151
]

tests/conftest.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Root test configuration for redsun.
2+
3+
Defines the ``qt`` marker and automatically skips Qt-dependent tests
4+
when no display is available (headless CI without ``QT_QPA_PLATFORM=offscreen``).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import os
10+
import sys
11+
12+
import pytest
13+
14+
15+
def _has_display() -> bool:
16+
"""Return True if a Qt display environment is available."""
17+
# offscreen platform works everywhere — check first
18+
if os.environ.get("QT_QPA_PLATFORM") == "offscreen":
19+
return True
20+
# X11 / Wayland display on Linux
21+
if sys.platform == "linux":
22+
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
23+
# macOS and Windows always have a display in normal environments
24+
return True
25+
26+
27+
_SKIP_QT = pytest.mark.skip(
28+
reason="requires a Qt display; set QT_QPA_PLATFORM=offscreen or run with pytest-env"
29+
)
30+
31+
32+
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
33+
"""Auto-skip @pytest.mark.qt tests in headless environments."""
34+
if _has_display():
35+
return
36+
for item in items:
37+
if item.get_closest_marker("qt"):
38+
item.add_marker(_SKIP_QT)

tests/container/test_container.py

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22

33
from __future__ import annotations
44

5-
import os
6-
import sys
75
from pathlib import Path
86
from typing import Any
97

108
import pytest
119

12-
from redsun.containers import AppContainer, AppConfig, StorageConfig, device, presenter, view
10+
from redsun.containers import (
11+
AppConfig,
12+
AppContainer,
13+
StorageConfig,
14+
device,
15+
presenter,
16+
view,
17+
)
1318
from redsun.containers.components import (
1419
_DeviceComponent,
1520
_PresenterComponent,
@@ -61,10 +66,7 @@ def test_presenter_component_build(self) -> None:
6166
assert presenter is comp.instance
6267
assert "built" in repr(comp)
6368

64-
@pytest.mark.skipif(
65-
sys.platform == "linux" and not os.environ.get("DISPLAY"),
66-
reason="requires a display (Qt)",
67-
)
69+
@pytest.mark.qt
6870
def test_view_component_build(self) -> None:
6971
from mock_pkg.view import MockQtView
7072
from qtpy.QtWidgets import QApplication
@@ -277,10 +279,7 @@ class TestApp(AppContainer):
277279
assert "ctrl" in TestApp._presenter_components
278280
assert isinstance(TestApp._presenter_components["ctrl"], _PresenterComponent)
279281

280-
@pytest.mark.skipif(
281-
sys.platform == "linux" and not os.environ.get("DISPLAY"),
282-
reason="Fails on Linux CI without a display (Qt required for view components)",
283-
)
282+
@pytest.mark.qt
284283
def test_component_field_collects_view(self) -> None:
285284
from mock_pkg.view import MockQtView
286285
from qtpy.QtWidgets import QApplication
@@ -442,7 +441,6 @@ class TestAppConfig:
442441

443442
def test_app_config_has_schema_version(self) -> None:
444443
from redsun.containers import AppConfig
445-
from redsun.virtual import RedSunConfig
446444

447445
cfg: AppConfig = {
448446
"schema_version": 1.0,
@@ -457,7 +455,6 @@ def test_app_config_has_schema_version(self) -> None:
457455
assert "session" in AppConfig.__optional_keys__
458456

459457
def test_app_config_has_component_fields(self) -> None:
460-
from redsun.containers import AppConfig
461458

462459
cfg: AppConfig = {
463460
"schema_version": 1.0,
@@ -478,14 +475,15 @@ def test_redsun_config_no_component_fields(self) -> None:
478475
assert "views" not in RedSunConfig.__annotations__
479476

480477

478+
@pytest.mark.qt
481479
class TestQtAppContainer:
482480
"""Tests for QtAppContainer lifecycle correctness."""
483481

484482
def test_build_before_run_creates_qapplication(self) -> None:
485-
from mock_pkg.view import MockQtView
486483
from mock_pkg.device import MyMotor
487-
484+
from mock_pkg.view import MockQtView
488485
from qtpy.QtWidgets import QApplication
486+
489487
from redsun.qt import QtAppContainer
490488

491489
class _TestQtApp(QtAppContainer):
@@ -508,6 +506,7 @@ class _TestQtApp(QtAppContainer):
508506

509507
def test_run_reuses_qapplication_created_by_build(self) -> None:
510508
from qtpy.QtWidgets import QApplication
509+
511510
from redsun.qt import QtAppContainer
512511

513512
class _TestQtApp(QtAppContainer):
@@ -528,7 +527,7 @@ class TestComponentNaming:
528527
"""
529528

530529
def test_device_alias_overrides_attr_name(self) -> None:
531-
"""alias takes priority over the attribute name as device name."""
530+
"""Alias takes priority over the attribute name as device name."""
532531
from mock_pkg.device import MyMotor
533532

534533
class TestApp(AppContainer):
@@ -561,7 +560,7 @@ class TestApp(AppContainer):
561560
assert app.devices["motor"].name == "motor"
562561

563562
def test_presenter_alias_overrides_attr_name(self) -> None:
564-
"""alias takes priority over the attribute name for presenters."""
563+
"""Alias takes priority over the attribute name for presenters."""
565564
from mock_pkg.controller import MockController
566565
from mock_pkg.device import MyMotor
567566

@@ -603,6 +602,7 @@ class TestStorageInjection:
603602
def mock_writer(self):
604603
"""Patch _build_writer to return a MagicMock, avoiding acquire-zarr dependency."""
605604
from unittest.mock import MagicMock, patch
605+
606606
from redsun.storage import Writer
607607

608608
writer = MagicMock(spec=Writer)
@@ -667,7 +667,7 @@ class TestApp(AppContainer):
667667
assert not hasattr(motor, "storage")
668668

669669
def test_storage_none_when_no_config(self) -> None:
670-
"""Without a storage section, device.storage remains None."""
670+
"""Without a storage section, device.storage is not injected."""
671671
from mock_pkg.device import MockDetectorWithStorage
672672

673673
class TestApp(AppContainer):
@@ -686,7 +686,7 @@ class TestApp(AppContainer):
686686
app.build()
687687

688688
cam = app.devices["cam"]
689-
assert cam.storage is None # type: ignore[union-attr]
689+
assert not hasattr(cam, "storage")
690690

691691
def test_storage_injected_via_inheritance(
692692
self, tmp_path: Path, mock_writer: Any
@@ -696,6 +696,7 @@ def test_storage_injected_via_inheritance(
696696

697697
class ExtendedDetector(MockDetectorWithStorage):
698698
"""Subclass that inherits StorageDescriptor from MockDetectorWithStorage."""
699+
699700
pass
700701

701702
class TestApp(AppContainer):
@@ -766,10 +767,12 @@ class TestApp(AppContainer, config=str(cfg_file)):
766767

767768
def test_default_base_path_uses_session_name(self) -> None:
768769
"""When base_path is omitted, _build_writer is called with the session name."""
769-
from unittest.mock import MagicMock, patch, call
770-
from redsun.storage import Writer
770+
from unittest.mock import MagicMock, patch
771+
771772
from mock_pkg.device import MockDetectorWithStorage
772773

774+
from redsun.storage import Writer
775+
773776
writer = MagicMock(spec=Writer)
774777

775778
class TestApp(AppContainer):
@@ -798,8 +801,9 @@ class TestApp(AppContainer):
798801

799802
def test_build_writer_creates_session_directory(self, tmp_path: Path) -> None:
800803
"""_build_writer creates ~/redsun-storage/<session> when base_path is omitted."""
801-
from unittest.mock import patch
802804
from pathlib import Path as _Path
805+
from unittest.mock import patch
806+
803807
from redsun.containers.container import _build_writer
804808

805809
storage_cfg = StorageConfig(backend="zarr")

tests/sdk/test_view.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
from redsun.virtual import VirtualContainer, IsInjectable, IsProvider
2-
from redsun.view import View, ViewPosition, PView
3-
from redsun.view.qt import QtView
1+
import pytest
42
from qtpy import QtWidgets as QtW
53

4+
from redsun.view import PView, View, ViewPosition
5+
from redsun.view.qt import QtView
6+
from redsun.virtual import IsInjectable, IsProvider, VirtualContainer
7+
68

79
def test_qtview_subclassing() -> None:
810
"""Test that QtView is a virtual subclass of View."""
@@ -29,6 +31,7 @@ def view_position(self) -> ViewPosition:
2931
assert view.view_position == ViewPosition.CENTER
3032

3133

34+
@pytest.mark.qt
3235
def test_presenter_is_provider(bus: VirtualContainer) -> None:
3336
"""Test that a presenter can optionally implement IsProvider."""
3437

@@ -69,6 +72,7 @@ def inject_dependencies(self, container: VirtualContainer) -> None:
6972
assert issubclass(InjectableView, IsInjectable)
7073

7174

75+
@pytest.mark.qt
7276
def test_base_qt_view(bus: VirtualContainer) -> None:
7377
"""Test basic QtView functionality."""
7478

uv.lock

Lines changed: 26 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)