153 lines
4.9 KiB
Python
153 lines
4.9 KiB
Python
"""Fixtures for the AutoCal tests.
|
|
|
|
Everything here runs offscreen with no controller and no hardware. Two
|
|
environment facts are set before Qt is imported:
|
|
|
|
* ``QT_QPA_PLATFORM=offscreen`` so a window can be built without a display;
|
|
* ``QT_QPA_FONTDIR`` because the offscreen platform finds no fonts on Windows
|
|
otherwise, and every label would measure as zero-width.
|
|
|
|
The suite requires PySide6 — the app's only Qt binding. Where it is missing
|
|
(an older interpreter, or a machine with no way to install it) the tests are
|
|
reported as skipped rather than failing, so a broken environment cannot look
|
|
like a broken application.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
os.environ.setdefault("ORISYS_APP_ID", "autocal")
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
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:
|
|
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
|
|
collect_ignore_glob = ["test_*.py"]
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def qt_app():
|
|
"""One ``QApplication`` per session (Qt forbids more than one)."""
|
|
if not HAVE_PYSIDE6: # pragma: no cover
|
|
pytest.skip("PySide6 is not installed")
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
app = QApplication.instance() or QApplication([])
|
|
yield app
|
|
|
|
|
|
SCRATCH_ROOT = Path(__file__).resolve().parents[1] / "outputs" / "_test_scratch"
|
|
|
|
|
|
def _scratch_dir() -> Path:
|
|
"""A fresh scratch directory under the app's own (gitignored) outputs."""
|
|
import time
|
|
|
|
path = SCRATCH_ROOT / f"{os.getpid()}-{int(time.time() * 1000) % 1_000_000}"
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_dir():
|
|
"""A workspace-local scratch directory, cleared per test.
|
|
|
|
Deliberately not ``tmp_path``: this suite has to run inside a sandboxed
|
|
shell, and ``tmp_path`` depends on a writable process temp root that such an
|
|
environment may not provide.
|
|
"""
|
|
import shutil
|
|
|
|
path = _scratch_dir()
|
|
yield path
|
|
shutil.rmtree(path, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def make_window(qt_app, monkeypatch):
|
|
"""Build a window whose preference writes stay inside a scratch directory.
|
|
|
|
Deliberately not ``tmp_path``: this suite has to run inside a sandboxed
|
|
shell, and ``tmp_path`` depends on a writable process temp root that such an
|
|
environment may not provide. Using the app's own ``outputs/`` (already
|
|
gitignored) removes that dependency entirely.
|
|
"""
|
|
import shutil
|
|
|
|
from orisys_app_shared import paths as paths_mod
|
|
from orisys_app_shared import prefs as prefs_pkg
|
|
|
|
from orisys_autocal import preferences as prefs_mod
|
|
from orisys_autocal.motion import DemoTransport
|
|
from orisys_autocal.ui.window import AutoCalMainWindow
|
|
|
|
scratch = _scratch_dir()
|
|
prefs_file = scratch / "preference.json"
|
|
monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file)
|
|
monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file)
|
|
monkeypatch.setattr(prefs_mod, "preference_path", lambda: prefs_file)
|
|
|
|
created = []
|
|
|
|
def _make(**overrides):
|
|
prefs = prefs_mod.default_preferences()
|
|
prefs.update(overrides)
|
|
# The translator is process-global, so a window built after another test
|
|
# switched language would otherwise keep that language and then be
|
|
# labelled with the wrong locale.
|
|
from orisys_app_shared.i18n import set_locale
|
|
|
|
set_locale(str(prefs.get("language") or "zh_CN"))
|
|
window = AutoCalMainWindow(prefs=prefs)
|
|
# Swap in the simulated platform: no test in this suite touches a DLL.
|
|
window.poller.service._transport = DemoTransport() # noqa: SLF001
|
|
created.append(window)
|
|
return window
|
|
|
|
yield _make
|
|
|
|
for window in created:
|
|
try:
|
|
window.poller.shutdown()
|
|
window.close()
|
|
except Exception: # noqa: BLE001 - teardown must not mask a failure
|
|
pass
|
|
shutil.rmtree(scratch, ignore_errors=True)
|
|
|
|
|
|
def pump(app, seconds: float = 0.2) -> None:
|
|
"""Run the event loop for a short, bounded time."""
|
|
import time
|
|
|
|
deadline = time.monotonic() + seconds
|
|
while time.monotonic() < deadline:
|
|
app.processEvents()
|
|
time.sleep(0.005)
|
|
|
|
|
|
def visible_descendants(widget):
|
|
"""Yield every visible descendant of ``widget``."""
|
|
for child in widget.findChildren(object):
|
|
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
|
|
continue
|
|
if child.isVisible():
|
|
yield child
|