433b76e43c
把丢失的 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。
187 lines
6.4 KiB
Python
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)
|