"""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 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