Files
orisys-app/apps/studio/tests/conftest.py
T
2026-09-21 09:34:56 +08:00

200 lines
6.0 KiB
Python

"""Shared fixtures for the studio app UI tests.
The tests render real Qt widgets with the offscreen platform plugin, so they need
no display and no camera hardware. A single ``QApplication`` is shared by the
whole session (Qt forbids creating more than one).
The suite requires PySide6, the app's only Qt binding. Where it is missing the
modules are skipped rather than failed, so a broken environment cannot look like a
broken application (the same rule ``apps/autocal`` follows).
"""
from __future__ import annotations
import itertools
import os
import shutil
import sys
from pathlib import Path
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
# In a development environment that also has PyQt5 installed, pyqtgraph would otherwise
# bind to it (it takes whichever toolkit it finds first) and the plots would break.
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
def _have_pyside6() -> tuple[bool, str]:
try:
import PySide6
except ImportError as exc: # pragma: no cover - depends on the machine
return False, str(exc)
return True, getattr(PySide6, "__version__", "unknown")
HAVE_PYSIDE6, PYSIDE6_VERSION = _have_pyside6()
if not HAVE_PYSIDE6: # pragma: no cover - depends on the machine
# Mirrors apps/autocal/tests/conftest.py: nothing is collected, so a machine without
# the binding reports no tests instead of a wall of import errors.
collect_ignore_glob = ["test_*.py"]
# ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of
# truth between the pytest suite and the dependency-free checker.
_TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools"
if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(_TOOLS_DIR))
@pytest.fixture(scope="session")
def qt_app():
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
@pytest.fixture
def make_window(qt_app, tmp_path, monkeypatch):
"""Build a ``StudioMainWindow`` whose preference writes stay in tmp_path."""
from orisys_app_shared import prefs as prefs_pkg
from orisys_app_shared import paths as paths_mod
from orisys_studio.app_window import StudioMainWindow
prefs_file = tmp_path / "preference.json"
monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file)
monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file)
default_prefs = {
"language": "zh_CN",
"theme": "dark",
"sensor_type": "box",
"source_mode": "camera",
}
created = []
def _make(**overrides):
data = dict(default_prefs)
data.update(overrides)
window = StudioMainWindow(prefs=data)
created.append(window)
return window
yield _make
for window in created:
try:
window.close()
except Exception:
pass
def visible_descendants(widget):
"""Yield every visible descendant widget of ``widget``."""
for child in widget.findChildren(object):
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
continue
if child.isVisible():
yield child
def show_window(window, size):
"""Show a window at ``size`` and flush pending Qt events."""
from PySide6.QtWidgets import QApplication
window.resize(*size)
window.show()
QApplication.processEvents()
return window
def running_window(make_window):
"""Return a running window backed by a controllable test worker."""
import ui_check
window = show_window(
make_window(sensor_type="box", source_mode="camera"),
(1440, 880),
)
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
window._start_run()
window._run_timer.stop()
window._set_phase("running")
return window, stuck
def finish_run(window, stuck):
"""Stop a test run and release its controllable worker."""
window._stop_run(reason="user_stop")
stuck.release()
_RECORD_SCRATCH = (
Path(__file__).resolve().parents[1] / "outputs" / "_test_record"
)
_RECORD_3D_SCRATCH = (
Path(__file__).resolve().parents[1] / "outputs" / "_test_record_3d"
)
@pytest.fixture
def record_root():
"""A clean workspace-local directory for recording tests."""
shutil.rmtree(_RECORD_SCRATCH, ignore_errors=True)
_RECORD_SCRATCH.mkdir(parents=True, exist_ok=True)
yield _RECORD_SCRATCH
shutil.rmtree(_RECORD_SCRATCH, ignore_errors=True)
@pytest.fixture
def next_record_root():
"""Create isolated recording directories for comparisons within one test."""
shutil.rmtree(_RECORD_3D_SCRATCH, ignore_errors=True)
_RECORD_3D_SCRATCH.mkdir(parents=True, exist_ok=True)
counter = itertools.count()
def _next() -> Path:
path = _RECORD_3D_SCRATCH / f"run{next(counter)}"
path.mkdir(parents=True, exist_ok=True)
return path
yield _next
shutil.rmtree(_RECORD_3D_SCRATCH, ignore_errors=True)
@pytest.fixture
def replay_recording(record_root):
"""One short finished recording (video, forces, frames, and metadata)."""
import ui_check
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
recorder = ExperimentRecorder(
RecordConfig(video=True, fps=30.0, segment_seconds=30.0)
)
recording = recorder.start(
root=record_root,
session_id=1,
metadata={
"device": {
"sensor_type": "box",
"config_ref": str(record_root / "sensor.json"),
"config_sha256": "recorded",
},
"versions": {"sdk": "0.5.1"},
"depth": {"stored": False, "replay": "recompute"},
},
)
for index in range(60):
recorder.on_packet(ui_check._fake_packet(index))
recorder.stop()
yield Path(recording)