GUI: busy-state UX, role login defaults, control panel fixes #140
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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};")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,7 +35,7 @@ class OmegaPanel(QWidget):
|
||||
grid_layout.setColumnStretch(0, 1)
|
||||
grid_layout.setColumnStretch(1, 1)
|
||||
omega_settings = [
|
||||
{"name": " 0°", "payload": {"abs": 0.0}},
|
||||
{"name": "Go to 0°", "payload": {"abs": 0.0}},
|
||||
{"name": "-10°", "payload": {"rel": -10.0}},
|
||||
{"name": "+10°", "payload": {"rel": 10.0}},
|
||||
{"name": "-45°", "payload": {"rel": -45.0}},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()))
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user