Files
orisys-app/apps/studio/tests/conftest.py
T
ykcheungab 433b76e43c 修复 Studio 失败测试并新增合并门禁
把丢失的 box 录像放回 apps/studio/tests/data/,修掉第 3 节 3–10 号与两处过时测试桩,
注册 timing 标记并让根目录收集零错误;新增 tools/gate.ps1 与可选的 pre-push 钩子。
验证:studio 524 通过 19 跳过、shared 467 通过 18 跳过、ui_check 632 项 0 失败、gate.ps1 退出码 0。
细节见 docs/test-repair-plan.zh.md。
2026-10-18 19:24:31 +08:00

187 lines
6.4 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 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))
# The tests' own directory, so ``_studio_helpers`` is importable however pytest was
# started. Relying on pytest to insert it works only under the default import mode and
# only for the directory a test file happens to sit in; naming it here is what lets the
# suite be collected from the repository root (see ``docs/test-repair-plan.zh.md`` §4).
_TESTS_DIR = Path(__file__).resolve().parent
if str(_TESTS_DIR) not in sys.path:
sys.path.insert(0, str(_TESTS_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
)
_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)