GUI: busy-state UX, role login defaults, control panel fixes #140

Merged
duan_j merged 27 commits from fix/graphics-and-recovery into main 2026-08-17 13:42:00 +02:00
27 changed files with 941 additions and 78 deletions
+8 -2
View File
@@ -72,7 +72,11 @@ jobs:
BEAMLINE: SIMULATED
run: |
source .venv/bin/activate
pytest ./tests/unit
# -vv: one line per test, so a hung job shows the exact test it
# died in, not just a file of dots. -s: no output capture — prints
# and logs stream live instead of vanishing with a wedged worker
# (a pxiii_bec job once sat silent at 81% for hours).
pytest -s -vv ./tests/unit
test-with-beamline-plugins:
runs-on: ubuntu-latest
@@ -105,7 +109,9 @@ jobs:
BEAMLINE: SIMULATED
run: |
source .venv/bin/activate
pytest ./tests/unit
# Same -s -vv rationale as the plain test job: live, per-test
# output so a plugin-variant hang is diagnosable from the log.
pytest -s -vv ./tests/unit
test-with-coverage:
runs-on: ubuntu-latest
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

+2 -3
View File
@@ -21,9 +21,8 @@ def main():
try:
basedir = os.path.dirname(__file__)
icon_path = os.path.join(basedir, "graphics/aaregui_logo.svg")
# The banner is an SVG now; the old aare_banner.png no longer exists
# and yielded a null splash pixmap.
banner_path = os.path.join(basedir, "graphics/aare_banner.svg")
# SVG looks strange on consoles...only show one line
banner_path = os.path.join(basedir, "graphics/aare_banner.png")
except Exception:
logger.exception("Failed to load resources for splash screen")
sys.exit(1)
+142 -20
View File
@@ -198,7 +198,7 @@ class MainWindow(QMainWindow):
self._base_url = base_url
self._token = token
self._mounting = False
self._watching_motion = False
self._samcam_feed_banner_active = False
self._samcam_feed_banner_message = "Sample camera feed unavailable"
@@ -361,6 +361,10 @@ class MainWindow(QMainWindow):
beamline_layout = QVBoxLayout(beamline_page)
beamline_layout.setContentsMargins(0, 0, 0, 0)
self.samcam = SamcamPanel(beamline_page)
# Non-staff keep the staff-only banners visible (locked stand-ins, a
# click explains the gate) so the Beamline tab reads the same for
# every role instead of looking amputated.
self._locked_beamline_banners: list[TitleLabel] = []
if self._decoded_token.staff:
self.monochromator_panel = MonochromatorPanel(beamline_page)
self.abr_tweak = AbrTweakWidget(beamline_page)
@@ -371,6 +375,21 @@ class MainWindow(QMainWindow):
beamline_layout.addWidget(self.monochromator_panel)
beamline_layout.addWidget(self.abr_tweak)
beamline_layout.addWidget(self.beam_config)
else:
for title in ("Beamline setup", "ABR meas. pos.", "Beam configuration"):
# Holder with default layout margins so the banner aligns with
# the real panels' banners (same trick as BeamConfigPanel).
holder = QWidget(beamline_page)
holder_layout = QVBoxLayout(holder)
holder_layout.setSpacing(0)
banner = TitleLabel(title, holder)
banner.setCursor(Qt.CursorShape.PointingHandCursor)
banner.setToolTip("Only accessible for beamline scientists")
# eventFilter install happens LATE in __init__ (next to
# installEventFilter(self)) — see the comment there.
holder_layout.addWidget(banner)
beamline_layout.addWidget(holder)
self._locked_beamline_banners.append(banner)
# Samcam last: the beam panels are the ones tweaked most.
beamline_layout.addWidget(self.samcam)
beamline_layout.addStretch()
@@ -402,7 +421,14 @@ class MainWindow(QMainWindow):
# Dewar-tabs look: first banner flush under the tab bar (no top
# margin) and the tab row starting at the banners' left edge. The
# Experiment side needs two levels: the frame AND its first panel.
beamline_first = self.monochromator_panel if self._decoded_token.staff else self.samcam
beamline_first = (
self.monochromator_panel
if self._decoded_token.staff
# Non-staff: the first locked banner's holder is the column's
# first widget now, not samcam.
else self._locked_beamline_banners[0].parentWidget()
)
assert beamline_first is not None # the locked banner always has its holder
for first in (beamline_first, self.data_collection, self.data_collection.file_path_panel):
first_layout = first.layout()
assert first_layout is not None # panels build their layouts in __init__
@@ -596,6 +622,9 @@ class MainWindow(QMainWindow):
# Tracks beamline-state TRANSITIONS for the default sample-tab switch
# (see _apply_default_sample_tab).
self._last_beamline_state: BeamlineStateEnum | None = None
# None = not yet evaluated, so the first status tick always applies
# the gate (the GUI may start while a visitor pgroup is active).
self._beamline_tab_gated: bool | None = None
# Wrapper for the left inset: QTabWidget ignores its own contents
# margins for the tab bar, so the padding lives one level up. Aligns
@@ -856,6 +885,15 @@ class MainWindow(QMainWindow):
self.daq = DAQWorker(base_url=self._base_url, token=self._token)
self.installEventFilter(self)
# These share MainWindow.eventFilter, which reads sample_lists_tabs
# and left_column_tabs on every event — so they must install only
# HERE, after every attribute the filter touches exists. Installing
# them at widget-construction time made each early event raise
# AttributeError inside the filter ("Error calling Python override
# of QObject::eventFilter()" spam that poisoned CI teardowns).
self.left_column_tabs.tabBar().installEventFilter(self)
for locked_banner in self._locked_beamline_banners:
locked_banner.installEventFilter(self)
self._idle_timer = QTimer(self)
self._idle_timer.setInterval(60_000)
@@ -913,6 +951,7 @@ class MainWindow(QMainWindow):
self.sample_camera.smargon.connect(self.daq.move_smargon)
self.beamline.smargon_panel.smargon.connect(self.daq.move_smargon)
self.sample_camera.samcam_updated.connect(self.daq.samcam_settings)
self.sample_camera.open_full_help.connect(self.show_controls_help)
self.raster.omega.connect(self.daq.set_omega)
self.raster.smargon.connect(self.daq.move_smargon)
@@ -924,6 +963,10 @@ class MainWindow(QMainWindow):
self.beamline.illumination_panel.front_light.connect(self.daq.front_light)
self.beamline.illumination_panel.back_light.connect(self.daq.back_light)
# Energy row inside Exp. Config. — not staff-gated like the
# Beamline setup copy; the server enforces write permission anyway.
self.data_collection.change_energy.connect(self.daq.change_energy)
if self._decoded_token.staff:
self.monochromator_panel.mono_pitch_scan.connect(self.daq.mono_pitch_scan)
self.monochromator_panel.change_energy.connect(self.daq.change_energy)
@@ -1382,6 +1425,29 @@ class MainWindow(QMainWindow):
"The Auxiliary puck (reference tools) view is available to staff accounts only.",
)
def _show_beamline_staff_only_popup(self) -> None:
QMessageBox.information(
self,
"Staff only",
"Beamline configuration is available to staff accounts only.\n"
"Log in with a staff account or contact your local contact "
"to configure the beamline.",
)
def _show_beamline_tab_gated_popup(self) -> None:
# The tab is only ever disabled by the pgroup gate; staff hitting it
# are running a visitor's pgroup, users simply lack the rights.
if self._decoded_token.staff:
QMessageBox.information(
self,
"Beamline tab disabled",
"The active pgroup is not one of yours, so the Beamline tab "
"is disabled while running a visitor's experiment. Switch "
"back to your own pgroup to configure the beamline.",
)
else:
self._show_beamline_staff_only_popup()
@Slot()
def _raise_reference_tools(self) -> None:
if not self._decoded_token.staff:
@@ -1910,7 +1976,7 @@ class MainWindow(QMainWindow):
self._theme_action_group = QActionGroup(self)
self._theme_action_group.setExclusive(True)
self._use_legacy_theme_action = QAction("Sunrise Theme (default)", self)
self._use_legacy_theme_action = QAction("Sunrise Theme", self)
self._use_legacy_theme_action.setCheckable(True)
self._use_legacy_theme_action.setChecked(self._theme_mode == THEME_SUNRISE)
self._use_legacy_theme_action.triggered.connect(self.use_legacy_theme)
@@ -1922,7 +1988,7 @@ class MainWindow(QMainWindow):
self._use_portrait_theme_action.triggered.connect(self.use_portrait_theme)
self._theme_action_group.addAction(self._use_portrait_theme_action)
self._use_bluebird_theme_action = QAction("Bluebird Theme", self)
self._use_bluebird_theme_action = QAction("Bluebird Theme (default)", self)
self._use_bluebird_theme_action.setCheckable(True)
self._use_bluebird_theme_action.setChecked(self._theme_mode == THEME_BLUEBIRD)
self._use_bluebird_theme_action.triggered.connect(self.use_bluebird_theme)
@@ -2573,10 +2639,37 @@ class MainWindow(QMainWindow):
else:
self.sample_lists_tabs.setCurrentIndex(0)
def _apply_pgroup_gate(self, pgroup: str | None) -> None:
# Switching to a pgroup outside the token's own (admin) pgroups means
# running a visitor's experiment: the Beamline tab is then off-limits,
# and the Experiment tab comes up with Dataset path + Exp. Config.
# open. Applied only on gate TRANSITIONS so a manual tab choice
# survives the 1 Hz status ticks.
gated = pgroup is not None and pgroup not in (self._decoded_token.pgroups or [])
if gated == self._beamline_tab_gated:
return
self._beamline_tab_gated = gated
self.left_column_tabs.setTabEnabled(0, not gated)
self.left_column_tabs.setTabToolTip(
0, "Only accessible for beamline scientists" if gated else ""
)
if gated:
self.left_column_tabs.setCurrentIndex(1)
# persist=False: programmatic open must not overwrite the user's
# saved per-panel collapse choice (same rule as the session gate).
for banner in self.data_collection.findChildren(TitleLabel):
if banner.text() in ("Dataset path", "Experiment configuration"):
banner.set_collapsed(False, persist=False)
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
self._latest_daq_status = s
self._apply_session_gate(getattr(getattr(s, "session", None), "session", None))
self._apply_pgroup_gate(getattr(getattr(s, "session", None), "current_pgroup", None))
# No login-default state: the GUI adopts whatever state the server
# reports and never posts a transition on startup — a state POST can
# move motors, and merely logging in must not move hardware.
# Default tab only on state TRANSITIONS — a manual tab choice
# survives while the state stays put.
@@ -2630,11 +2723,16 @@ class MainWindow(QMainWindow):
if self._remote_close_deadline_ts is not None:
self._clear_remote_close_request()
if not self._mounting and s.state == BeamlineStateEnum.RobotSampleExchange:
self._mounting = True
# Busy (or the robot station) means something is physically moving:
# show the Beamline combined view so the motion can be watched, and
# return to the sample camera once it is done. Edge-triggered so a
# manual tab choice survives between transitions.
moving = bool(s.busy) or s.state == BeamlineStateEnum.RobotSampleExchange
if moving and not self._watching_motion:
self._watching_motion = True
self.video_tab.setCurrentWidget(self.beamline_combined_panel)
elif self._mounting and s.state != BeamlineStateEnum.RobotSampleExchange:
self._mounting = False
elif not moving and self._watching_motion:
self._watching_motion = False
self.video_tab.setCurrentWidget(self.sample_camera)
# ========== BATON DIALOG HANDLING ==========
@@ -3126,18 +3224,42 @@ class MainWindow(QMainWindow):
self._mark_user_interaction()
except Exception as e:
logger.debug(f"GUI interaction event filter error: {e}", exc_info=True)
# Non-staff click on the greyed-out Auxiliary-puck tab: only installed
# for non-staff, and tabAt() is geometric so it still sees the
# disabled tab — explain the lock instead of silently eating the click.
sample_tab_bar = self.sample_lists_tabs.tabBar()
if (
event.type() == QEvent.Type.MouseButtonPress
and sample_tab_bar is not None
and obj is sample_tab_bar
and sample_tab_bar.tabAt(event.position().toPoint()) == 1
):
self._show_reference_tools_staff_only_popup()
return True
# getattr defaults: this filter also runs for events delivered while
# __init__ is still building (or teardown is tearing down) the very
# widgets it inspects — a raise here spams every event and breaks
# widget cleanup, so missing attributes must mean "not my click".
if event.type() == QEvent.Type.MouseButtonPress:
# Non-staff click on the greyed-out Auxiliary-puck tab: only
# installed for non-staff, and tabAt() is geometric so it still
# sees the disabled tab — explain the lock instead of silently
# eating the click.
sample_tabs = getattr(self, "sample_lists_tabs", None)
sample_tab_bar = sample_tabs.tabBar() if sample_tabs is not None else None
if (
sample_tab_bar is not None
and obj is sample_tab_bar
and sample_tab_bar.tabAt(event.position().toPoint()) == 1
):
self._show_reference_tools_staff_only_popup()
return True
# Non-staff click on a locked Beamline banner: same treatment as
# the Auxiliary-puck tab — explain the gate, don't eat the click.
if obj in getattr(self, "_locked_beamline_banners", ()):
self._show_beamline_staff_only_popup()
return True
# Click on the pgroup-gated (disabled) Beamline tab: tabAt() is
# geometric, so it still sees the disabled tab under the cursor.
left_tabs = getattr(self, "left_column_tabs", None)
if left_tabs is not None:
left_tab_bar = left_tabs.tabBar()
if (
left_tab_bar is not None
and obj is left_tab_bar
and left_tab_bar.tabAt(event.position().toPoint()) == 0
and not left_tabs.isTabEnabled(0)
):
self._show_beamline_tab_gated_popup()
return True
return super().eventFilter(obj, event)
def _start_remote_close_countdown(
+14
View File
@@ -71,6 +71,13 @@ class AbrTweakButtons(QWidget):
self.gmz_label.setAlignment(Qt.AlignmentFlag.AlignRight)
grid_layout.addWidget(self.gmz_label, 2, 3)
# Reserve room for the sign up front: the column is content-sized, so
# without this it widens whenever a value flips negative and the whole
# row shifts. Right alignment keeps the digits anchored in the gap.
sign_width = self.fontMetrics().horizontalAdvance("-88.888")
for label in (self.gmx_label, self.gmy_label, self.gmz_label):
label.setMinimumWidth(sign_width)
@Slot(dict)
def abr_button(self, payload: dict):
self.abr_tweak.emit(
@@ -149,6 +156,13 @@ class AbrTweakWidget(QWidget):
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
# TODO(confirm intent): labels DISPLAY aerotech_meas (saved measurement
# pos) but the red highlight CHECKS aerotech (live stage pos), so a
# label can read 0.000 and still be red. Guess: red was meant to warn
# "live stage is off the meas pos", i.e. the check should be
# abs(aerotech - aerotech_meas) >= 0.001 per axis; alternative reading
# is it should flag the displayed meas value itself being nonzero.
# Confirm which and re-evaluate before changing the condition.
self._abr_buttons.gmx_label.setText(f"{s.geom.aerotech_meas.x:.3f}")
if abs(s.geom.aerotech.x) >= 0.001:
self._abr_buttons.gmx_label.setStyleSheet(f"color: {ALERT_TEXT};")
+7 -1
View File
@@ -132,6 +132,7 @@ class BeamlineStatePanel(QFrame):
self._current_state: BeamlineStateEnum | None = None
self._hovered_state: BeamlineStateEnum | None = None
self._pending_target_state: BeamlineStateEnum | None = None
self._busy = False
# After 3 s of hovering an unavailable state, explain which states
# it can be reached from.
@@ -238,7 +239,9 @@ class BeamlineStatePanel(QFrame):
def _available_targets(self) -> frozenset[BeamlineStateEnum]:
current = self._current_state
if current is None or current == BeamlineStateEnum.Moving:
# Busy greys the whole strip like Moving does: a transition posted
# mid-operation would clobber it (same guard as the status-bar menu).
if self._busy or current is None or current == BeamlineStateEnum.Moving:
return frozenset()
# Reachable in one step: the route graph plus the status-bar
# shortcut transitions.
@@ -399,4 +402,7 @@ class BeamlineStatePanel(QFrame):
self._apply_highlight()
def update_daq_status(self, status: DAQStatusModel) -> None:
self._busy = bool(status.busy)
# set_current_state re-applies the highlight, so a busy flip
# restyles the strip on the same tick even if the state held.
self.set_current_state(status.state)
@@ -3,8 +3,10 @@ from aarecommon.math.sample_geometry import SampleGeometryModel
from aarecommon.models.models import DAQStatusModel
from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import (
QDoubleSpinBox,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QStackedWidget,
@@ -26,6 +28,8 @@ from aare.gui.widgets.title_label import TitleLabel, tighten_column
class DataCollectionSettings(QFrame):
cancel = Signal()
# Same convention as the Beamline setup panel: keV in the GUI, eV on emit.
change_energy = Signal(float)
set_width = 400
@@ -81,6 +85,22 @@ class DataCollectionSettings(QFrame):
centering_layout.addWidget(self.find_tip)
centering_layout.addWidget(self.bounding_box)
# Energy row copied from the Beamline setup panel so users can change
# energy without leaving the experiment configuration.
self.energy_spin = QDoubleSpinBox(parent=self)
self.energy_spin.setDecimals(3)
self.energy_spin.setRange(1.0, 30.0)
self.energy_spin.setSingleStep(0.1)
self.energy_spin.setValue(12.0)
self.change_energy_button = QPushButton("Change Energy", parent=self)
self.change_energy_button.clicked.connect(self._emit_change_energy)
energy_row = QWidget(self)
energy_layout = QHBoxLayout(energy_row)
energy_layout.setContentsMargins(0, 0, 0, 0)
energy_layout.addWidget(QLabel("Energy (keV)", parent=energy_row))
energy_layout.addWidget(self.energy_spin)
energy_layout.addWidget(self.change_energy_button)
# Pane frame carries the border QTabWidget::pane used to draw
# (#expConfigPane rule in styles.py).
pane = QFrame(self)
@@ -90,6 +110,7 @@ class DataCollectionSettings(QFrame):
# border, and the Abort button should hug the pane.
pane_layout.setContentsMargins(6, 6, 6, 0)
pane_layout.addWidget(centering_row)
pane_layout.addWidget(energy_row)
pane_layout.addWidget(self._stack)
# Own container: TitleLabel collapse hides its siblings, so without it
@@ -147,6 +168,10 @@ class DataCollectionSettings(QFrame):
page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical)
self._stack.adjustSize()
@Slot()
def _emit_change_energy(self):
self.change_energy.emit(float(self.energy_spin.value()) * 1000.0)
@Slot()
def switch_to_raster(self):
self._tab_bar.setCurrentIndex(0)
+38 -11
View File
@@ -1,9 +1,15 @@
import time
from aarecommon.models.models import DAQStatusModel
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import QGridLayout, QLabel, QSlider, QWidget
from aare.gui.widgets.title_label import TitleLabel
# Skip readback updates this long after a user change: a status response already
# in flight when the PUT was queued would otherwise snap the slider back.
READBACK_GRACE_S = 1.5
class IlluminationPanel(QWidget):
front_light = Signal(int)
@@ -21,11 +27,18 @@ class IlluminationPanel(QWidget):
front_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
grid_layout.addWidget(front_label, 1, 0, 1, 2)
self.is_sliding = False
self._last_user_change = 0.0
self.front_light_slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self)
self.front_light_slider.setRange(0, 100)
# tracking off: valueChanged fires once per deliberate change (wheel notch,
# key step, groove page-step, drag release). sliderReleased alone missed
# every path except a handle drag, so wheel adjustments were never sent
# and the 500 ms status poll reverted them.
self.front_light_slider.setTracking(False)
self.front_light_slider.sliderPressed.connect(self.on_slider_pressed)
self.front_light_slider.sliderReleased.connect(self.on_front_slider_released)
self.front_light_slider.sliderReleased.connect(self.on_slider_released)
self.front_light_slider.valueChanged.connect(self.on_front_value_changed)
grid_layout.addWidget(self.front_light_slider, 2, 0, 1, 2)
back_label = QLabel("Back light", parent=self)
@@ -34,8 +47,10 @@ class IlluminationPanel(QWidget):
self.back_light_slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self)
self.back_light_slider.setRange(0, 100)
self.back_light_slider.setTracking(False)
self.back_light_slider.sliderPressed.connect(self.on_slider_pressed)
self.back_light_slider.sliderReleased.connect(self.on_back_slider_released)
self.back_light_slider.sliderReleased.connect(self.on_slider_released)
self.back_light_slider.valueChanged.connect(self.on_back_value_changed)
grid_layout.addWidget(self.back_light_slider, 4, 0, 1, 2)
@Slot()
@@ -43,17 +58,29 @@ class IlluminationPanel(QWidget):
self.is_sliding = True
@Slot()
def on_front_slider_released(self):
def on_slider_released(self):
self.is_sliding = False
self.front_light.emit(self.front_light_slider.value())
@Slot()
def on_back_slider_released(self):
self.is_sliding = False
self.back_light.emit(self.back_light_slider.value())
@Slot(int)
def on_front_value_changed(self, v: int):
self._last_user_change = time.monotonic()
self.front_light.emit(v)
@Slot(int)
def on_back_value_changed(self, v: int):
self._last_user_change = time.monotonic()
self.back_light.emit(v)
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
if not self.is_sliding: # Update only if not sliding
self.front_light_slider.setValue(round(s.bl.front_light))
self.back_light_slider.setValue(round(s.bl.back_light))
if self.is_sliding or time.monotonic() - self._last_user_change < READBACK_GRACE_S:
return
# blockSignals: readback must not loop back into valueChanged and echo
# a PUT to the server every poll.
for slider, val in (
(self.front_light_slider, s.bl.front_light),
(self.back_light_slider, s.bl.back_light),
):
slider.blockSignals(True)
slider.setValue(round(val))
slider.blockSignals(False)
+17 -2
View File
@@ -8,16 +8,21 @@ from aare.gui.widgets.title_label import TitleLabel
class MonochromatorPanel(QWidget):
mono_pitch_scan = Signal()
change_energy = Signal(float)
move_beam_to_box = Signal()
def __init__(self, parent=None):
super().__init__(parent)
grid_layout = QGridLayout(self)
grid_layout.addWidget(
TitleLabel("Monochromator", self, collapsible=True, default_collapsed=False), 0, 0, 1, 3
TitleLabel("Beamline setup", self, collapsible=True, default_collapsed=False),
0,
0,
1,
3,
)
self.mono_pitch_scan_button = QPushButton("Mono Pitch Scan", parent=self)
self.mono_pitch_scan_button = QPushButton("Mono Pitch Rock and Go to Max", parent=self)
self.mono_pitch_scan_button.clicked.connect(self.mono_pitch_scan.emit)
grid_layout.addWidget(self.mono_pitch_scan_button, 1, 0, 1, 3)
@@ -38,6 +43,16 @@ class MonochromatorPanel(QWidget):
self.change_energy_button.clicked.connect(self._emit_change_energy)
grid_layout.addWidget(self.change_energy_button, 2, 2)
# TODO(wire backend): no DAQ endpoint exists yet for moving the beam
# to the box center — shown disabled as WIP until the operation is
# defined server-side; then drop "(WIP)", enable, and connect the
# signal in main_window.
self.move_beam_to_box_button = QPushButton("Move Beam to Box (center) (WIP)", parent=self)
self.move_beam_to_box_button.setToolTip("Coming soon — not functional yet.")
self.move_beam_to_box_button.setEnabled(False)
self.move_beam_to_box_button.clicked.connect(self.move_beam_to_box.emit)
grid_layout.addWidget(self.move_beam_to_box_button, 3, 0, 1, 3)
@Slot()
def _emit_change_energy(self):
self.change_energy.emit(float(self.energy_spin.value()) * 1000.0)
+1 -1
View File
@@ -35,7 +35,7 @@ class OmegaPanel(QWidget):
grid_layout.setColumnStretch(0, 1)
grid_layout.setColumnStretch(1, 1)
omega_settings = [
{"name": " ", "payload": {"abs": 0.0}},
{"name": "Go to", "payload": {"abs": 0.0}},
{"name": "-10°", "payload": {"rel": -10.0}},
{"name": "+10°", "payload": {"rel": 10.0}},
{"name": "-45°", "payload": {"rel": -45.0}},
+24 -17
View File
@@ -5,6 +5,7 @@ from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QGridLayout, QLabel, QPushButton, QWidget
from aare.gui.widgets.button_with_payload import ButtonWithPayload
from aare.gui.widgets.motor_move_group import MotorMoveGroup
from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.title_label import TitleLabel
@@ -59,34 +60,43 @@ class SmargonPanel(QWidget):
grid_layout = QGridLayout(self)
grid_layout.addWidget(
TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 0, 0, 1, 6
TitleLabel("Smargon", self, collapsible=True, default_collapsed=False), 0, 0, 1, 7
)
grid_layout.addWidget(QLabel("Chi", parent=self), 1, 0)
self.chi_enter = NumberLineEdit(-0.2, 40, decimals=1, parent=self)
self.chi_enter.newValue.connect(self.chi)
grid_layout.addWidget(self.chi_enter, 1, 1)
grid_layout.addWidget(QLabel("°", parent=self), 1, 2)
grid_layout.addWidget(QLabel("Phi", parent=self), 1, 3)
self.phi_enter = NumberLineEdit(-0.2, 360, decimals=1, parent=self)
self.phi_enter.newValue.connect(self.phi)
grid_layout.addWidget(self.phi_enter, 1, 4)
grid_layout.addWidget(QLabel("°", parent=self), 1, 5)
# Chi/Phi are staged (orange) and only sent on Move (green until the
# motor reports the target reached) — see MotorMoveGroup.
self.move_group = MotorMoveGroup(parent=self)
self.move_group.add_box("chi", self.chi_enter)
self.move_group.add_box("phi", self.phi_enter)
self.move_group.applied.connect(self._move_axes)
# Own row: sharing row 1 squeezed the Chi/Phi entry boxes.
grid_layout.addWidget(self.move_group.button, 2, 0, 1, 7)
self.home_button = QPushButton("Move home", parent=self)
grid_layout.addWidget(self.home_button, 2, 0, 1, 6)
grid_layout.addWidget(self.home_button, 3, 0, 1, 7)
self.home_button.clicked.connect(self.home)
self.move_panel = SmargonMoveWidget(parent=self)
grid_layout.addWidget(self.move_panel, 3, 0, 1, 6)
grid_layout.addWidget(self.move_panel, 4, 0, 1, 7)
self.move_panel.smargon_rel.connect(self.smargon_rel)
grid_layout.addWidget(QLabel("Step", parent=self), 4, 0)
grid_layout.addWidget(QLabel("Step", parent=self), 5, 0)
self.step = NumberLineEdit(1, 1000, 100, 0, parent=self)
grid_layout.addWidget(self.step, 4, 1)
grid_layout.addWidget(QLabel("μm", parent=self), 4, 2)
# Span to the μm label so the box gets real width instead of the
# narrow column shared with the Chi entry.
grid_layout.addWidget(self.step, 5, 1, 1, 4)
grid_layout.addWidget(QLabel("μm", parent=self), 5, 5)
self.step.newValue.connect(self.step_changed)
@Slot(float)
@@ -98,18 +108,15 @@ class SmargonPanel(QWidget):
# TODO move SMARGON_HOME to REDIS, allow GUI to read this value
self.smargon.emit(SmargonCoordinate(sh_mm=Coordinate(x=0, y=0, z=18), phi_deg=0, chi_deg=0))
@Slot(float)
def phi(self, f: float):
self.smargon.emit(SmargonCoordinate(phi_deg=f))
@Slot(float)
def chi(self, f: float):
self.smargon.emit(SmargonCoordinate(chi_deg=f))
@Slot(dict)
def _move_axes(self, targets: dict[str, float]):
# only the staged axes move — absent keys stay None (no motion)
self.smargon.emit(SmargonCoordinate(chi_deg=targets.get("chi"), phi_deg=targets.get("phi")))
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
self.chi_enter.update_value(s.geom.smargon.chi_deg)
self.phi_enter.update_value(s.geom.smargon.phi_deg)
self.move_group.update_actual("chi", s.geom.smargon.chi_deg)
self.move_group.update_actual("phi", s.geom.smargon.phi_deg)
self._geom = s.geom
@Slot(Coordinate)
+24
View File
@@ -348,6 +348,12 @@ INPUT_BG = "rgba(255, 255, 255, 33%)"
INPUT_INVALID_BG = "#e9c4cf" # red 20% over latte base
INPUT_DISABLED_BG = "#e6e9ef" # latte mantle
INPUT_DISABLED_INVALID_BG = "#ecdae2" # faint red wash
# Motor batch-move states (MotorMoveGroup "movestate" dynamic property):
# staged-but-not-sent target vs motor in motion — see motor_move_group.py.
INPUT_PENDING_BG = "#f2cdba" # peach 25% over latte base
INPUT_MOVING_BG = "#c3ddc3" # green 25% over latte base
DARK_INPUT_PENDING_BG = "#473741" # warn (copper) 25% over bg
DARK_INPUT_MOVING_BG = "#2f4e5d" # green 25% over bg
# -- Status bar flags (Catppuccin Latte) ------------------------------------
STATUS_OK = "#40a02b" # closed / idle / owned / tell ok (green)
@@ -715,6 +721,16 @@ def _sunrise_stylesheet(overrides: dict[str, str] | None = None) -> str:
background-color: $input_disabled_bg;
}
/* Motor batch-move states (before the invalid rules on purpose: equal
specificity, so out-of-range red must come last to win). */
QLineEdit[movestate="pending"] {
background-color: $input_pending_bg;
}
QLineEdit[movestate="moving"] {
background-color: $input_moving_bg;
}
QLineEdit[invalid="true"] {
background-color: $input_invalid_bg;
}
@@ -1380,6 +1396,14 @@ def _sunset_stylesheet() -> str:
background-color: $dark_disabled;
}
QLineEdit[movestate="pending"] {
background-color: $dark_input_pending_bg;
}
QLineEdit[movestate="moving"] {
background-color: $dark_input_moving_bg;
}
QLineEdit[invalid="true"] {
background-color: $dark_error_bg;
}
@@ -87,6 +87,10 @@ class PredictionSubscriber(QThread):
self._measure_focus = enabled
def _try_parse_json(self, part: bytes) -> dict | None:
# Binary frames (JPEG starts with 0xff) are not JSON; decoding them as
# utf-8 raises UnicodeDecodeError on every frame. Cheap sniff instead.
if part.lstrip()[:1] != b"{":
return None
try:
decoded = json.loads(part.decode("utf-8"))
return decoded if isinstance(decoded, dict) else None
@@ -254,11 +258,9 @@ class PredictionSubscriber(QThread):
self.running = False
self.requestInterruption()
try:
if self._sock is not None:
self._sock.close(0)
except Exception:
logger.debug("Error while stopping the prediction subscriber", exc_info=True)
# Do NOT close the socket here: zmq sockets are not thread-safe, and
# closing from the GUI thread while run() uses it aborts libzmq
# (Assertion failed: pfd.revents & POLLIN, signaler.cpp). RCVTIMEO=500ms
# guarantees run() notices running=False and closes it in its own thread.
if not self.wait(1500):
logger.warning("PredictionSubscriber did not stop within timeout")
+22
View File
@@ -23,6 +23,7 @@ from aare.gui.styles import (
BUSY_YELLOW_BORDER,
BUSY_YELLOW_DOT,
BUSY_YELLOW_TEXT_DARK,
SHADOW,
WHITE,
qcolor,
)
@@ -99,6 +100,27 @@ def draw_busy_badge(
return bg_rect
def draw_busy_status_text(
painter: QPainter, viewport_width: int, viewport_height: int, style: BusyOverlayStyle
) -> None:
"""Non-clickable busy status for the passive Axis views: solid colored
text with a 1px shadow, no badge box — the pill read as a button (same
look as the sample camera's own warning text). Same position/typeface as
draw_busy_badge so the message reads identically everywhere."""
font = QFont()
font.setPointSize(24)
font.setBold(True)
font_metrics = QFontMetrics(font)
painter.setFont(font)
x = (viewport_width - font_metrics.horizontalAdvance(style.text)) // 2
baseline = int(viewport_height * 0.68) + font_metrics.ascent() // 2
painter.setPen(QPen(qcolor(SHADOW, 200)))
painter.drawText(QPoint(x + 1, baseline + 1), style.text)
painter.setPen(QPen(qcolor(style.badge_bg)))
painter.drawText(QPoint(x, baseline), style.text)
def build_busy_overlay_style(
*,
is_busy: bool,
+42 -2
View File
@@ -98,6 +98,8 @@ class SampleCameraImageLabel(QGraphicsView):
samcam_updated = Signal(SampleCameraSettings)
switch_raster_grid = Signal()
# "More…" link in the help overlay; main window opens the full F1 dialog.
open_full_help = Signal()
def __init__(
self,
@@ -147,6 +149,7 @@ class SampleCameraImageLabel(QGraphicsView):
# legend visibility is already handled by the panel checkboxes.
self._help_expanded = False
self._help_hit_rect: QRectF | None = None # viewport coords, set on paint
self._help_more_rect: QRectF | None = None # "More…" link inside the overlay
self._target_point = None
self._target_shape = None
self._target_color_name = "Cyan"
@@ -471,6 +474,19 @@ class SampleCameraImageLabel(QGraphicsView):
self._scaling()
def mousePressEvent(self, event):
# "More…" link before the box toggle: its rect lies inside the help box,
# so the toggle below would swallow the click otherwise.
if (
event.button() == Qt.MouseButton.LeftButton
and self._help_more_rect is not None
and self._help_more_rect.contains(QPointF(self.viewport().mapFrom(self, event.pos())))
):
self._help_expanded = False
self.update()
self.open_full_help.emit()
event.accept()
return
# Help badge first: pure UI affordance, must work even when camera
# interaction is disabled (session overlay etc.).
if (
@@ -1087,6 +1103,7 @@ class SampleCameraImageLabel(QGraphicsView):
def _draw_help_overlay(self, painter: QPainter):
self._help_hit_rect = None
self._help_more_rect = None
if not self._help_expanded:
self._draw_help_badge(painter)
return
@@ -1114,8 +1131,15 @@ class SampleCameraImageLabel(QGraphicsView):
for entry in entries:
max_width = max(max_width, fm.horizontalAdvance(entry))
more_text = "More… (F1)"
max_width = max(max_width, fm.horizontalAdvance(more_text))
width = max_width + padding * 2
height = len(self._HELP_SECTIONS) * header_height + n_lines * line_height + padding * 2
height = (
len(self._HELP_SECTIONS) * header_height
+ (n_lines + 1) * line_height # +1: trailing "More…" link line
+ padding * 2
)
bg_rect = QRectF(18, max(18, self.viewport().height() - height - 18), width, height)
self._help_hit_rect = bg_rect # click anywhere on the box to close
@@ -1135,6 +1159,16 @@ class SampleCameraImageLabel(QGraphicsView):
painter.drawText(QPointF(bg_rect.left() + padding, y + fm.ascent()), entry)
y += line_height
# Trailing link to the full F1 dialog; underlined so it reads as clickable.
link_font = QFont(font)
link_font.setUnderline(True)
painter.setFont(link_font)
painter.setPen(QPen(qcolor(LEGEND_TEXT), 1))
painter.drawText(QPointF(bg_rect.left() + padding, y + fm.ascent()), more_text)
self._help_more_rect = QRectF(
bg_rect.left() + padding, y, fm.horizontalAdvance(more_text), line_height
)
painter.restore()
def _draw_overlay_legend(self, painter: QPainter):
@@ -1354,7 +1388,13 @@ class SampleCameraImageLabel(QGraphicsView):
def wheelEvent(self, event: QWheelEvent):
if not self.wheel_event_timer.isActive():
delta_y = event.angleDelta().y()
# Qt's xcb/windows platform plugins swap wheel axes while Alt is held,
# so a vertical scroll lands in angleDelta().x() and y() is 0. Without
# this fallback, copysign(_, 0) is always positive and Alt+wheel
# exposure can only ever increase.
delta_y = event.angleDelta().y() or event.angleDelta().x()
if delta_y == 0:
return
match self._state:
case SampleCameraImageState.BEAM_MARKING:
if event.modifiers() & Qt.KeyboardModifier.AltModifier:
+132
View File
@@ -0,0 +1,132 @@
"""Batch entry -> apply for motor value boxes.
Why: typing into a motor box used to fire the move on Enter, one axis at a
time. Operators want to stage several targets, review them, then start all
motors with one click. The group tracks a per-box state machine:
neutral (theme default) --user edit--> pending (orange)
pending --Move clicked--> moving (green)
moving --actual reaches target--> neutral
Colors are QSS dynamic properties ("movestate" on the box), styled per theme
in styles.py — same pattern as NumberLineEdit's "invalid" property, so the
existing out-of-range red still wins while typing.
Modular on purpose: any panel opts a NumberLineEdit in with add_box(); boxes
not registered keep their old behavior.
"""
from PySide6.QtCore import QEvent, QObject, QPoint, Qt, Signal, Slot
from PySide6.QtWidgets import QPushButton, QToolTip
from aare.gui.widgets.number_line_edit import NumberLineEdit
class MotorMoveGroup(QObject):
# {name: target} for every box that was pending when Move was clicked
applied = Signal(dict)
def __init__(self, button_text: str = "Move", parent=None):
super().__init__(parent)
self.button = QPushButton(button_text)
self.button.setEnabled(False)
self.button.clicked.connect(self._apply)
self._boxes: dict[str, NumberLineEdit] = {}
self._tols: dict[str, float] = {}
self._state: dict[str, str] = {} # "" (neutral) | "pending" | "moving"
self._targets: dict[str, float] = {}
def add_box(self, name: str, box: NumberLineEdit, tol: float = 0.1):
# tol is the "at target" window — hardware never lands exactly on the
# setpoint, so this stays a per-box knob (encoder resolution differs
# per motor).
self._boxes[name] = box
self._tols[name] = tol
self._state[name] = ""
box.textEdited.connect(lambda _text, n=name: self._on_edited(n))
# Below-min can only be judged on Enter (typing "3" may become "30"),
# and the validator swallows editingFinished for out-of-range text —
# so catch the key directly.
box.installEventFilter(self)
# -- panel-facing API ----------------------------------------------------
@Slot(str, float)
def update_actual(self, name: str, value: float):
"""Feed the motor's actual position. Neutral boxes track it (same as
the old direct update_value call); pending/moving boxes keep showing
the user's target until the move completes."""
box = self._boxes[name]
state = self._state[name]
if state == "moving" and abs(value - self._targets[name]) <= self._tols[name]:
self._set_state(name, "")
state = ""
if state == "":
box.update_value(value)
# -- internals -----------------------------------------------------------
def _on_edited(self, name: str):
box = self._boxes[name]
try:
value = float(box.text())
except ValueError:
# incomplete entry ("", "-", "1e"): stay/become pending, no tips
self._set_state(name, "pending")
return
top = box.range_validator.top()
if value > top:
QToolTip.showText(
box.mapToGlobal(QPoint(0, box.height())),
f"Maximum value: {box.to_string(top)}",
box,
)
# editing back to the current position cancels the pending move
if abs(value - box.saved_value) <= self._tols[name]:
self._set_state(name, "")
else:
self._set_state(name, "pending")
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.KeyPress and event.key() in (
Qt.Key.Key_Return,
Qt.Key.Key_Enter,
):
for box in self._boxes.values():
if obj is box:
try:
value = float(box.text())
except ValueError:
break
bottom = box.range_validator.bottom()
if value < bottom:
QToolTip.showText(
box.mapToGlobal(QPoint(0, box.height())),
f"Too small — minimum value: {box.to_string(bottom)}",
box,
)
break
return super().eventFilter(obj, event)
@Slot()
def _apply(self):
targets = {}
for name, box in self._boxes.items():
# out-of-range text stays pending (red via the invalid property);
# only valid targets are sent
if self._state[name] == "pending" and box.validate(box.text()):
targets[name] = box.value
self._targets[name] = box.value
self._set_state(name, "moving")
if targets:
self.applied.emit(targets)
# ponytail: no timeout — a move that never reaches target leaves the
# box green until the user re-edits it; add a watchdog if that bites.
def _set_state(self, name: str, state: str):
box = self._boxes[name]
if box.property("movestate") != state:
box.setProperty("movestate", state)
# property selectors only re-evaluate on repolish
box.style().unpolish(box)
box.style().polish(box)
self._state[name] = state
self.button.setEnabled(any(s == "pending" for s in self._state.values()))
+9 -7
View File
@@ -17,12 +17,14 @@ class NumberLineEdit(QLineEdit):
self._read_only: bool = False
self._is_valid: bool = True
# Use a QDoubleValidator to only allow valid floating point numbers
self.validator = QDoubleValidator()
self.validator.setNotation(QDoubleValidator.Notation.StandardNotation)
# Use a QDoubleValidator to only allow valid floating point numbers.
# Named range_validator: plain "validator" would shadow
# QLineEdit.validator() and break type checking at call sites.
self.range_validator = QDoubleValidator()
self.range_validator.setNotation(QDoubleValidator.Notation.StandardNotation)
self.decimal_count = decimals
self.validator.setRange(min_val, max_val, self.decimal_count)
self.setValidator(self.validator)
self.range_validator.setRange(min_val, max_val, self.decimal_count)
self.setValidator(self.range_validator)
self.setAlignment(Qt.AlignmentFlag.AlignRight)
self.setToolTip(
f"Minimum: {self.to_string(min_val):s}\nMaximum: {self.to_string(max_val):s}"
@@ -80,13 +82,13 @@ class NumberLineEdit(QLineEdit):
@Slot(float, float)
def update_limits(self, min_val: float, max_val: float):
self.validator.setRange(min_val, max_val, self.decimal_count)
self.range_validator.setRange(min_val, max_val, self.decimal_count)
self.setToolTip(
f"Minimum: {self.to_string(min_val):s}\nMaximum: {self.to_string(max_val):s}"
)
def validate(self, text) -> bool:
return self.validator.validate(str(text), 0)[0] == QDoubleValidator.State.Acceptable
return self.range_validator.validate(str(text), 0)[0] == QDoubleValidator.State.Acceptable
def setReadOnly(self, ro: bool):
# Colors follow via the QSS :read-only pseudo-class (updates without
+4 -4
View File
@@ -2,7 +2,7 @@ from PySide6.QtCore import QRectF, Qt, Slot
from PySide6.QtGui import QImage, QPainter, QPixmap
from PySide6.QtWidgets import QGraphicsPixmapItem, QGraphicsScene, QGraphicsView
from aare.gui.widgets.busy_overlay import BusyOverlayStyle, draw_busy_badge
from aare.gui.widgets.busy_overlay import BusyOverlayStyle, draw_busy_status_text
class VideoGraphicsView(QGraphicsView):
@@ -114,9 +114,9 @@ class VideoGraphicsView(QGraphicsView):
painter.save()
painter.resetTransform()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
# Shared renderer with the sample camera, so every view shows the
# identical badge (this view used to draw its own dot+text variant).
draw_busy_badge(
# Plain shadowed text like the sample camera's warnings, not the
# badge pill: these views are passive and the pill read as a button.
draw_busy_status_text(
painter, self.viewport().width(), self.viewport().height(), self._busy_overlay_style
)
painter.restore()
+36
View File
@@ -0,0 +1,36 @@
"""The Axis views' busy flag is a passive status: plain shadowed text, not
the badge pill (which reads as a button). These checks fail if the text
renderer stops painting or the video view stops routing through it."""
from PySide6.QtCore import Qt
from PySide6.QtGui import QPainter, QPixmap
from aare.gui.widgets.busy_overlay import build_busy_overlay_style, draw_busy_status_text
from aare.gui.widgets.video_image import VideoGraphicsView
def _busy_style():
style = build_busy_overlay_style(is_busy=True, tell_state=None)
assert style is not None and style.text == "BEAMLINE BUSY"
return style
def test_status_text_paints_something(qapp):
pixmap = QPixmap(400, 300)
pixmap.fill(Qt.GlobalColor.black)
blank = pixmap.toImage()
painter = QPainter(pixmap)
draw_busy_status_text(painter, 400, 300, _busy_style())
painter.end()
assert pixmap.toImage() != blank, "busy text must actually paint"
def test_video_view_paints_busy_text(qtbot):
view = VideoGraphicsView()
qtbot.addWidget(view)
view.resize(400, 300)
idle = view.grab().toImage()
view.set_busy_overlay_style(_busy_style())
assert view.grab().toImage() != idle, "busy style must change the rendered view"
view.set_busy_overlay_style(None)
assert view.grab().toImage() == idle, "clearing the style must restore the view"
+49
View File
@@ -160,3 +160,52 @@ def test_vacant_badge_hover_click_and_theme(camera, qtbot):
with qtbot.waitSignal(camera.session_badge_clicked, timeout=1000):
qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center())
def test_help_more_link_opens_full_dialog(camera, qtbot):
camera.grab() # paint records the "?" badge hit rect
badge = camera._help_hit_rect
qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center().toPoint())
assert camera._help_expanded
camera.grab() # expanded overlay paint records the More... link rect
more = camera._help_more_rect
assert more is not None
opened = []
camera.open_full_help.connect(lambda: opened.append(True))
qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=more.center().toPoint())
assert opened, "More... must open the full F1 dialog"
assert not camera._help_expanded # cheatsheet folds behind the dialog
def _wheel(widget, *, x=0, y=0, modifiers=Qt.KeyboardModifier.NoModifier):
from PySide6.QtGui import QWheelEvent
return QWheelEvent(
QPointF(5, 5),
QPointF(widget.mapToGlobal(QPoint(5, 5))),
QPoint(0, 0),
QPoint(x, y),
Qt.MouseButton.NoButton,
modifiers,
Qt.ScrollPhase.NoScrollPhase,
False,
)
def test_alt_wheel_axis_swap_still_changes_exposure(camera):
"""xcb/windows swap wheel axes while Alt is held: the scroll lands in
angleDelta().x(). The fallback must still reach the exposure branch —
and a genuinely empty wheel event must do nothing."""
camera.update_daq_status(_status(busy=False, session=SessionsStateEnum.OwnedByYou))
sent = []
camera.samcam_updated.connect(sent.append)
camera.wheelEvent(_wheel(camera, x=-120, modifiers=Qt.KeyboardModifier.AltModifier))
assert sent, "Alt+wheel with x-only delta must still adjust exposure"
assert sent[0].exposure < 0.02, "negative delta must decrease exposure"
camera.wheel_event_timer.stop() # bypass the throttle for the second event
n = len(sent)
camera.wheelEvent(_wheel(camera)) # zero delta: ignored
assert len(sent) == n
+23
View File
@@ -0,0 +1,23 @@
"""gui.main() startup path: the splash banner is a PNG again (the SVG
rendered wrong on RHEL9 consoles), so the resource block must resolve it —
and a failed startup must exit through the graceful error path, not crash."""
from unittest.mock import MagicMock, patch
import pytest
def test_main_resolves_banner_and_exits_gracefully(qapp):
from aare.gui import gui
with (
# A second QApplication would abort; hand main() a stand-in instead.
patch.object(gui, "QApplication", return_value=MagicMock()),
# process() on the stand-in app exits the interpreter at C level
# ("argument list cannot be empty") — option defaults still apply.
patch.object(gui.QCommandLineParser, "process", lambda self, app: None),
patch.object(gui, "QMessageBox"),
patch.object(gui, "auth", side_effect=RuntimeError("no server")),
pytest.raises(SystemExit),
):
gui.main()
+80
View File
@@ -0,0 +1,80 @@
"""Send/readback wiring for the light sliders (was a __main__ self-check in
the panel; promoted here so CI counts it). Every deliberate input path must
emit, and the status-poll readback must neither clobber a fresh user change
nor echo a PUT back to the server."""
import time
from types import SimpleNamespace
import pytest
from PySide6.QtCore import QPoint, QPointF, Qt
from PySide6.QtGui import QWheelEvent
from PySide6.QtWidgets import QApplication
from aare.gui.panels.illumination_panel import READBACK_GRACE_S, IlluminationPanel
@pytest.fixture
def panel(qtbot):
panel = IlluminationPanel()
qtbot.addWidget(panel)
return panel
def _wheel_notch(slider):
# One wheel notch; sliderReleased never fires for this path, which is
# exactly the case the valueChanged wiring exists for.
QApplication.sendEvent(
slider,
QWheelEvent(
QPointF(5, 5),
QPointF(5, 5),
QPoint(0, 0),
QPoint(0, 120),
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase,
False,
),
)
def _status(front=90.0, back=10.0):
return SimpleNamespace(bl=SimpleNamespace(front_light=front, back_light=back))
def test_wheel_notch_emits_both_lights(panel):
front, back = [], []
panel.front_light.connect(front.append)
panel.back_light.connect(back.append)
_wheel_notch(panel.front_light_slider)
_wheel_notch(panel.back_light_slider)
assert front, "wheel adjustment must emit front_light"
assert back, "wheel adjustment must emit back_light"
def test_readback_grace_holds_user_value(panel):
_wheel_notch(panel.front_light_slider)
held = panel.front_light_slider.value()
panel.update_daq_status(_status())
assert panel.front_light_slider.value() == held, "grace window must hold user value"
def test_readback_applies_after_grace_without_echo(panel):
sent = []
panel.front_light.connect(sent.append)
panel._last_user_change = time.monotonic() - READBACK_GRACE_S - 1
panel.update_daq_status(_status())
assert panel.front_light_slider.value() == 90, "readback must apply after grace"
assert panel.back_light_slider.value() == 10
assert not sent, "readback must not echo a PUT"
def test_readback_skipped_while_sliding(panel):
panel.on_slider_pressed()
panel._last_user_change = 0.0
panel.update_daq_status(_status(front=55.0))
assert panel.front_light_slider.value() != 55
panel.on_slider_released()
panel.update_daq_status(_status(front=55.0))
assert panel.front_light_slider.value() == 55
+94 -1
View File
@@ -1,7 +1,7 @@
from unittest.mock import MagicMock, patch
import pytest
from PySide6.QtCore import QSettings
from PySide6.QtCore import QSettings, Qt
from PySide6.QtWidgets import QDockWidget
from aare.gui.main_window import MainWindow
@@ -50,6 +50,27 @@ def test_main_window_init(qtbot, mock_ui_state):
assert win.windowTitle() == "AareGUI"
assert win.isVisible()
# Staff-side gate behaviors piggyback on this window: every extra
# MainWindow construction raises the odds of the PySide SystemError
# flake, so don't build another one just for these.
from aarecommon.models.models import BeamlineStateEnum
with patch("aare.gui.main_window.QMessageBox") as popup:
win._show_beamline_tab_gated_popup()
assert popup.information.called, "staff popup must explain the visitor pgroup"
win._apply_default_sample_tab(BeamlineStateEnum.BeamLocation)
assert win.sample_lists_tabs.currentIndex() == 1 # Auxiliary puck
win._apply_default_sample_tab(BeamlineStateEnum.DataCollection)
assert win.sample_lists_tabs.currentIndex() == 0
# Energy row inside Exp. Config. emits eV (the GUI shows keV)
sent = []
win.data_collection.change_energy.connect(sent.append)
win.data_collection.energy_spin.setValue(12.4)
win.data_collection._emit_change_energy()
assert sent and abs(sent[0] - 12400.0) < 1e-6
def test_main_window_mount_view(qtbot, mock_ui_state):
with (
@@ -213,6 +234,33 @@ def test_automation_activity_refreshes_idle_timestamp(qtbot, mock_ui_state):
assert win._last_user_interaction_ts == 250.0
# Remote-close bookkeeping in the same status loop: an unreadable
# session entry is skipped, a close request for THIS session starts
# the countdown, and its disappearance clears it again.
from types import SimpleNamespace
unreadable = status.model_copy(update={"open_guis": [SimpleNamespace(session="bad")]})
win.update_daq_status(unreadable)
assert win._remote_close_deadline_ts is None
close_req = status.model_copy(
update={
"open_guis": [
SimpleNamespace(
session=15,
close_requested=True,
close_requested_by="ops",
close_grace_seconds=5,
)
]
}
)
win.update_daq_status(close_req)
assert win._remote_close_deadline_ts is not None
win.update_daq_status(status)
assert win._remote_close_deadline_ts is None
def test_idle_timeout_closes_when_inactive_and_not_running(qtbot, mock_ui_state):
with (
@@ -484,3 +532,48 @@ def test_close_restores_pre_watch_layout(qtbot, mock_ui_state):
# closeEvent put the pre-watch layout back before saving state, so
# the all-hidden fold was not persisted.
assert not win.tell_samples_dock.isHidden()
def test_nonstaff_beamline_gate_popups(qtbot, mock_ui_state):
"""Non-staff must SEE the staff-only Beamline panels (as locked banners)
and get an explanation on click — both on a banner and on the
pgroup-gated (disabled) Beamline tab, which used to eat clicks silently."""
with (
patch("requests.get"),
patch("aare.gui.main_window.DAQWorker"),
patch("aare.gui.main_window.PredictionSubscriber"),
patch("aare.gui.main_window.VideoThread"),
patch("aare.gui.main_window.JFJochDBusClient"),
patch("aare.gui.main_window.jwt.decode") as mock_jwt,
):
mock_jwt.return_value = {
"sub": "visitor",
"staff": False,
"pgroups": ["p123"],
"session": 15,
}
win = _make_window(qtbot)
# Locked stand-ins exist in place of the real staff panels
titles = [b.text() for b in win._locked_beamline_banners]
assert titles == ["Beamline setup", "ABR meas. pos.", "Beam configuration"]
assert not hasattr(win, "monochromator_panel")
with patch("aare.gui.main_window.QMessageBox") as popup:
qtbot.mousePress(win._locked_beamline_banners[0], Qt.MouseButton.LeftButton)
assert popup.information.called, "banner click must explain the staff gate"
# Active pgroup outside the token disables the whole Beamline tab;
# a click on the disabled tab must explain itself, not vanish.
win._apply_pgroup_gate("p999")
assert not win.left_column_tabs.isTabEnabled(0)
bar = win.left_column_tabs.tabBar()
with patch("aare.gui.main_window.QMessageBox") as popup:
qtbot.mousePress(bar, Qt.MouseButton.LeftButton, pos=bar.tabRect(0).center())
assert popup.information.called, "gated tab click must explain the gate"
# The greyed-out Auxiliary-puck tab explains itself the same way.
aux_bar = win.sample_lists_tabs.tabBar()
with patch("aare.gui.main_window.QMessageBox") as popup:
qtbot.mousePress(aux_bar, Qt.MouseButton.LeftButton, pos=aux_bar.tabRect(1).center())
assert popup.information.called, "aux-puck tab click must explain the lock"
+94
View File
@@ -0,0 +1,94 @@
"""MotorMoveGroup is motor protection UX: typing must never start a move —
targets are staged (orange), sent only by the Move button (green), and the
box returns to neutral when the motor actually arrives."""
import pytest
from PySide6.QtCore import Qt
from aare.gui.widgets.motor_move_group import MotorMoveGroup
from aare.gui.widgets.number_line_edit import NumberLineEdit
@pytest.fixture
def chi(qtbot):
box = NumberLineEdit(-0.2, 40, decimals=1)
qtbot.addWidget(box)
group = MotorMoveGroup()
group.add_box("chi", box)
return group, box
def _type(qtbot, box, text):
box.clear()
qtbot.keyClicks(box, text)
def test_stage_apply_settle(chi, qtbot):
group, box = chi
sent = []
group.applied.connect(sent.append)
_type(qtbot, box, "25.0")
assert box.property("movestate") == "pending"
assert group.button.isEnabled()
assert not sent # typing (even Enter) must not move the motor
group.button.click()
assert sent == [{"chi": 25.0}]
assert box.property("movestate") == "moving"
assert not group.button.isEnabled()
group.update_actual("chi", 10.0) # still travelling
assert box.property("movestate") == "moving"
assert box.value == 25.0 # box keeps showing the target
group.update_actual("chi", 25.05) # within tol -> arrived
assert box.property("movestate") == ""
group.update_actual("chi", 3.0) # neutral boxes track the actual again
assert box.value == 3.0
def test_edit_back_to_current_cancels_pending(chi, qtbot):
group, box = chi
_type(qtbot, box, "5.0")
assert group.button.isEnabled()
_type(qtbot, box, "0.0") # back to the actual position
assert box.property("movestate") == ""
assert not group.button.isEnabled()
def test_over_max_is_invalid_and_never_sent(chi, qtbot):
group, box = chi
sent = []
group.applied.connect(sent.append)
_type(qtbot, box, "500")
assert box.property("invalid") is True # red via existing validator path
group.button.click()
assert not sent # out-of-range target is never applied
assert box.property("movestate") == "pending"
def test_incomplete_entry_stays_pending(chi, qtbot):
_group, box = chi
_type(qtbot, box, "-") # not a number (yet): no tips, no crash
assert box.property("movestate") == "pending"
# Enter on the incomplete text must be swallowed just as quietly
qtbot.keyClick(box, Qt.Key.Key_Return)
assert box.property("movestate") == "pending"
def test_below_min_enter_hints_but_never_sends(chi, qtbot):
group, box = chi
sent = []
group.applied.connect(sent.append)
_type(qtbot, box, "-5") # below the -0.2 bottom
qtbot.keyClick(box, Qt.Key.Key_Return) # triggers the min-value tooltip path
assert not sent # Enter must never start a move
def test_update_limits_reranges_box(chi, qtbot):
_group, box = chi
box.update_limits(-1.0, 50.0)
assert box.range_validator.bottom() == -1.0
assert box.range_validator.top() == 50.0
assert "50" in box.toolTip()
+18
View File
@@ -87,3 +87,21 @@ def test_status_panel_low_current(qtbot, mock_daq_status):
assert f"color: {STATUS_ALERT}" in panel.ring_current.text()
assert "300.0" in panel.ring_current.text()
def test_smargon_staged_axes_move_and_actual_tracking(qtbot, mock_daq_status):
"""_move_axes sends only the staged axes (absent keys stay None = no
motion) and the status tick feeds actuals back into the move group."""
from aare.gui.panels.smargon_panel import SmargonPanel
panel = SmargonPanel()
qtbot.addWidget(panel)
sent = []
panel.smargon.connect(sent.append)
panel._move_axes({"chi": 2.0})
assert sent and sent[0].chi_deg == 2.0
assert sent[0].phi_deg is None # phi was not staged: must not move
panel.update_daq_status(mock_daq_status)
assert panel._geom is mock_daq_status.geom
@@ -0,0 +1,27 @@
"""Checks the binary-frame sniff in _try_parse_json: JPEG frames (0xff lead
byte) must be rejected without attempting utf-8 decode (previously raised
UnicodeDecodeError on every frame)."""
from typing import cast
from aare.gui.threads.prediction_subscriber import PredictionSubscriber
def _parse(part: bytes):
# Called unbound: _try_parse_json touches no instance state, so no
# QThread/zmq construction is needed. cast keeps basedpyright happy
# about the stand-in self.
return PredictionSubscriber._try_parse_json(cast(PredictionSubscriber, object()), part)
def test_binary_jpeg_frame_is_not_json():
assert _parse(b"\xff\xd8\xff\xe0somejpegbytes") is None
def test_json_dict_is_parsed():
assert _parse(b'{"encoding": "jpeg"}') == {"encoding": "jpeg"}
def test_json_non_dict_is_rejected():
assert _parse(b"[1, 2]") is None
assert _parse(b"") is None
Generated
+1 -1
View File
@@ -31,7 +31,7 @@ wheels = [
[[package]]
name = "aaredaq"
version = "0.8.1"
version = "0.8.3"
source = { editable = "." }
dependencies = [
{ name = "aarecommon" },