Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 20 additions & 11 deletions src/hextools/gui/re_execution_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
114 changes: 114 additions & 0 deletions tests/gui/test_re_execution_controls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import os

import pytest

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("qtpy")
pytest.importorskip("bluesky_widgets")
Comment thread
sligara7 marked this conversation as resolved.

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