"""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 logging 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 paths as paths_mod from orisys_app_shared import prefs as prefs_pkg 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: # noqa: BLE001 - teardown must not fail a test that passed # Reported rather than swallowed: a window that cannot close is a Qt lifetime # problem worth seeing, but it must not turn a green test red. logging.getLogger(__name__).debug("could not close a test window", exc_info=True) @pytest.fixture def decline_video_prompt(monkeypatch): """Answer the bare-video setup prompt with "no", so it cannot block a test. Opening a video that is not a recording now asks *how* to analyse it, and the dialog is modal: a test that drives ``load_video`` on a bare video without answering waits for a person who is not there. Declining is the answer that leaves the old behaviour intact — the video plays, nothing is analysed — which is what the tests predating the prompt are about. """ from PySide6.QtWidgets import QDialog from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog monkeypatch.setattr( VideoAnalysisDialog, "exec", lambda self: QDialog.DialogCode.Rejected ) 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)