diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f00b6cd..7fbbc7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,31 @@ jobs: - name: Run tests run: pixi run --as-is -e ${{ steps.pixi.outputs.env }} pytest --cov=src/hextools --cov-report=xml --cov-report=term + gui-test: + name: GUI Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Install Qt system libraries + run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libfontconfig1 libdbus-1-3 + + - name: Setup Pixi + uses: prefix-dev/setup-pixi@2253d9c9310442c09975d64615591b5b381da6e7 # v0.10.0 + with: + pixi-version: v0.79.0 + environments: gui-dev + cache: false + frozen: true + + - name: Run GUI tests + env: + QT_QPA_PLATFORM: offscreen + run: pixi run --as-is -e gui-dev pytest tests/gui + # Uncomment once codecov is enabled. # - name: Upload coverage to Codecov # uses: codecov/codecov-action@v5 diff --git a/src/hextools/gui/re_execution_controls.py b/src/hextools/gui/re_execution_controls.py index a7a4101..571c3ad 100644 --- a/src/hextools/gui/re_execution_controls.py +++ b/src/hextools/gui/re_execution_controls.py @@ -16,9 +16,10 @@ from bluesky_widgets.qt.run_engine_client import ( QtReExecutionControls as _QtReExecutionControls, ) -from bluesky_widgets.qt.threading import FunctionWorker from qtpy.QtCore import QTimer +from hextools.gui._ipython import run_in_ipython + class _NullSignal: def connect(self, *args, **kwargs): @@ -74,7 +75,7 @@ def __init__( self._re_name = re_name self._namespace = namespace self._timer = None - self._resume_worker = None + self._resume_pending = False super().__init__(_NullModel() if local else model, parent) @@ -105,7 +106,7 @@ def _poll_local_state(self): paused = state == "paused" self._pb_plan_pause_deferred.setEnabled(running) self._pb_plan_pause_immediate.setEnabled(running) - self._pb_plan_resume.setEnabled(paused) + self._pb_plan_resume.setEnabled(paused and not self._resume_pending) self._pb_plan_stop.setEnabled(paused) self._pb_plan_abort.setEnabled(paused) self._pb_plan_halt.setEnabled(paused) @@ -132,16 +133,24 @@ def _pb_plan_pause_immediate_clicked(self): def _pb_plan_resume_clicked(self): if not self._local: return super()._pb_plan_resume_clicked() - # resume() blocks until the plan pauses or completes; run it off-thread. run_engine = self._resolve_re() - if run_engine is None or self._resume_worker is not None: + if run_engine is None or self._resume_pending: return - self._resume_worker = FunctionWorker(run_engine.resume) - self._resume_worker.finished.connect(self._on_resume_finished) - self._resume_worker.start() - - def _on_resume_finished(self): - self._resume_worker = None + # A second click before resume() returns would queue another resume(). + self._resume_pending = True + self._pb_plan_resume.setEnabled(False) + # resume() installs a SIGINT handler, which only the main thread may do, + # so run it after this click returns, never on a worker thread. + QTimer.singleShot(0, lambda: self._resume_on_main_thread(run_engine)) + + def _resume_on_main_thread(self, run_engine): + try: + if _ipython_namespace().get(self._re_name) is run_engine: + run_in_ipython(f"{self._re_name}.resume()") + else: + self._call_re("resume") + finally: + self._resume_pending = False def _pb_plan_stop_clicked(self): if not self._local: diff --git a/tests/gui/test_re_execution_controls.py b/tests/gui/test_re_execution_controls.py new file mode 100644 index 0000000..faf8ac9 --- /dev/null +++ b/tests/gui/test_re_execution_controls.py @@ -0,0 +1,114 @@ +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("qtpy") +pytest.importorskip("bluesky_widgets") + +import bluesky.plans as bp # noqa: E402 +import matplotlib # noqa: E402 +from bluesky import RunEngine # noqa: E402 +from bluesky import plan_stubs as bps # noqa: E402 +from IPython.core.interactiveshell import InteractiveShell # noqa: E402 +from ophyd.sim import det, motor # noqa: E402 +from qtpy.QtCore import QTimer # noqa: E402 +from qtpy.QtWidgets import QApplication # noqa: E402 + +from hextools.gui._ipython import run_in_ipython # noqa: E402, PLC2701 +from hextools.gui.re_execution_controls import QtReExecutionControls # noqa: E402 + + +def _slow_step(detectors, step, pos_cache): + yield from bps.one_nd_step(detectors, step, pos_cache) + yield from bps.sleep(0.1) + + +# RE.resume() installs a SIGINT handler, which Python allows only on the main +# thread; resuming from a worker thread raised and the plan stayed paused. +# PR 94 review: Resume must act on the engine the controls hold, even when the +# shell's RE is a different one. +@pytest.mark.parametrize("given", ["shell", "re", "namespace"]) +def test_resume_button_resumes_a_paused_plan(given): + # As in the GUI: with a Qt matplotlib backend, RE() keeps Qt events flowing. + matplotlib.use("qtagg") + app = QApplication.instance() or QApplication([]) + shell = InteractiveShell.instance() + RE = RunEngine({}) + shell.user_ns.update( + RE=RE if given == "shell" else RunEngine({}), + test_RE=RE, + bp=bp, + det=det, + motor=motor, + _slow_step=_slow_step, + ) + if given == "shell": + controls = QtReExecutionControls(local=True, namespace=shell.user_ns) + elif given == "re": + controls = QtReExecutionControls(local=True, re=RE) + else: + controls = QtReExecutionControls(local=True, namespace={"RE": RE}) + seen = [] + + def resume(): + seen.append(RE.state) + controls._pb_plan_resume_clicked() + + QTimer.singleShot( + 0, + lambda: run_in_ipython( + "test_RE(bp.scan([det], motor, 0, 1, 10, per_step=_slow_step))" + ), + ) + QTimer.singleShot(300, controls._pb_plan_pause_immediate_clicked) + QTimer.singleShot(800, resume) + QTimer.singleShot(5000, app.quit) + app.exec() + controls._timer.stop() + + assert seen == ["paused"] + assert RE.state == "idle", f"plan did not resume: state={RE.state}" + assert motor.position == pytest.approx(1) + + +# PR 94 review: a double-click on Resume must resume once, not queue a second +# resume() that could run through a later pause or hit an idle engine. +def test_double_click_on_resume_resumes_once(): + matplotlib.use("qtagg") + app = QApplication.instance() or QApplication([]) + shell = InteractiveShell.instance() + RE = RunEngine({}) + shell.user_ns.update(RE=RE, bp=bp, det=det, motor=motor, _slow_step=_slow_step) + controls = QtReExecutionControls(local=True, namespace=shell.user_ns) + resumes = [] + real_resume = RE.resume + + def counting_resume(): + resumes.append(RE.state) + return real_resume() + + RE.resume = counting_resume + enabled_after_click = [] + + def double_click(): + controls._poll_local_state() + controls._pb_plan_resume_clicked() + enabled_after_click.append(controls._pb_plan_resume.isEnabled()) + controls._pb_plan_resume_clicked() + + QTimer.singleShot( + 0, + lambda: run_in_ipython( + "RE(bp.scan([det], motor, 0, 1, 10, per_step=_slow_step))" + ), + ) + QTimer.singleShot(300, controls._pb_plan_pause_immediate_clicked) + QTimer.singleShot(800, double_click) + QTimer.singleShot(5000, app.quit) + app.exec() + controls._timer.stop() + + assert resumes == ["paused"], f"resume() called {len(resumes)} times: {resumes}" + assert enabled_after_click == [False] + assert RE.state == "idle"