Files
AareDAQ/tests/unit/gui/test_main_window.py
duan_jandClaude Fable 5 e9428ca64d
CI / test (3.11) (pull_request) Successful in 1m12s
CI / test (3.12) (pull_request) Successful in 1m13s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Successful in 1m12s
CI / lint (pull_request) Successful in 1m30s
CI / test (3.13) (pull_request) Successful in 1m25s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Successful in 1m21s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Successful in 1m27s
CI / test-with-coverage (pull_request) Successful in 1m43s
CI / coverage-analysis (pull_request) Successful in 4s
CI / lint (push) Successful in 28s
Docs build and publish / docker (push) Successful in 8s
CI / test (3.11) (push) Canceled after 35s
CI / test (3.12) (push) Canceled after 31s
CI / test (3.13) (push) Canceled after 30s
CI / test-with-beamline-plugins (pxi_bec) (push) Canceled after 26s
CI / test-with-beamline-plugins (pxii_bec) (push) Canceled after 25s
CI / test-with-beamline-plugins (pxiii_bec) (push) Canceled after 21s
CI / test-with-coverage (push) Canceled after 20s
CI / coverage-analysis (push) Canceled after 0s
Build and Publish / release (push) Successful in 24s
test: pin the Sample-alignment motion-watch exception
Robot motion switches to the combined view; alignment beginning
switches straight back while busy is still set, and staying busy in
alignment never re-triggers. Piggybacks on the init test's window -
extra MainWindow constructions feed the PySide teardown flake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:00:26 +02:00

599 lines
20 KiB
Python

from unittest.mock import MagicMock, patch
import pytest
from PySide6.QtCore import QSettings, Qt
from PySide6.QtWidgets import QDockWidget
from aare.gui.main_window import MainWindow
from aare.gui.styles import THEME_BLUEBIRD, THEME_SUNRISE, THEME_SUNSET
@pytest.fixture
def mock_ui_state():
with patch("aare.gui.main_window.UIStateManager") as mock:
yield mock
def test_main_window_init(qtbot, mock_ui_state, daq_status_factory):
with (
patch("requests.get") as mock_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_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"status": "ok"}
mock_jwt.return_value = {
"sub": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
fake_token = "header.payload.signature"
win = MainWindow(
base_url="http://localhost:5210",
token=fake_token,
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr="localhost",
gonio_cam_addr="localhost",
gonio_cam_id=1,
)
win.show()
qtbot.addWidget(win)
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
# Motion watch: robot motion switches to the combined beamline view;
# alignment beginning switches straight back to the sample camera even
# while the busy flag is still set — busy moves during Sample
# alignment ARE the alignment, so it never re-triggers the switch.
win.update_daq_status(
daq_status_factory(state=BeamlineStateEnum.RobotSampleExchange, busy=True)
)
assert win._watching_motion
assert win.video_tab.currentWidget() is win.beamline_combined_panel
win.update_daq_status(
daq_status_factory(state=BeamlineStateEnum.SampleAlignment, busy=True)
)
assert not win._watching_motion
assert win.video_tab.currentWidget() is win.sample_camera
win.update_daq_status(
daq_status_factory(state=BeamlineStateEnum.SampleAlignment, busy=True)
)
assert not win._watching_motion
def test_main_window_mount_view(qtbot, mock_ui_state):
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": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
fake_token = "header.payload.signature"
win = MainWindow(
base_url=None,
token=fake_token,
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr=None,
gonio_cam_addr=None,
gonio_cam_id=None,
)
qtbot.addWidget(win)
win.mount_view()
def test_mark_user_interaction_reports_backend(qtbot, mock_ui_state):
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": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
fake_token = "header.payload.signature"
win = MainWindow(
base_url=None,
token=fake_token,
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr=None,
gonio_cam_addr=None,
gonio_cam_id=None,
)
qtbot.addWidget(win)
win._last_interaction_report_ts = -999.0
win._mark_user_interaction()
win.daq.report_gui_interaction.assert_called_once_with(15)
def test_automation_activity_refreshes_idle_timestamp(qtbot, mock_ui_state):
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,
):
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
from aarecommon.math.diffraction_geometry import DiffractionGeometry
from aarecommon.math.sample_geometry import SampleGeometryModel
from aarecommon.models.models import (
BeamlineStateEnum,
BeamlineStatus,
CrystalSize,
DAQStatusModel,
SampleCameraSettings,
SessionsStateEnum,
SessionStatus,
)
mock_jwt.return_value = {
"sub": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
fake_token = "header.payload.signature"
win = MainWindow(
base_url=None,
token=fake_token,
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr=None,
gonio_cam_addr=None,
gonio_cam_id=None,
)
qtbot.addWidget(win)
win.job_list_panel.is_running = MagicMock(return_value=True)
win._last_user_interaction_ts = 100.0
status = DAQStatusModel(
geom=SampleGeometryModel(
beam_location_pxl=Coordinate(x=1000, y=1000),
pixel_in_mm=0.001,
aerotech=Coordinate(),
aerotech_meas=Coordinate(),
smargon=SmargonCoordinate(sh_mm=Coordinate(), phi_deg=0, chi_deg=0),
omega_deg=0,
beam_size_mm=Coordinate(x=0.01, y=0.01),
),
diffraction=DiffractionGeometry(
energy_keV=12.4,
dtz_mm=100.0,
detector_size_pxl=(1553, 1630),
pixel_size_mm=0.150,
beam_center_pxl=(750.0, 750.0),
detector_description="PILATUS 4",
detector_serial_number="1",
poni_rot1_rad=0.0,
poni_rot2_rad=0.0,
),
bl=BeamlineStatus(
name="SIMULATED",
ring_current_mA=400.0,
front_light=50.0,
back_light=50.0,
cryojet_K=100.0,
shutter_open=False,
exp_shutter_open=False,
flux_ph_s=1e12,
sample_camera=SampleCameraSettings(gain=1.0, exposure=0.02),
transmission=1.0,
zoom=1.0,
commissioning_mode=False,
dtz_min=120.0,
dtz_max=1600.0,
),
state=BeamlineStateEnum.SampleAlignment,
busy=False,
session=SessionStatus(
session=SessionsStateEnum.Vacant, current_pgroup="p123", staff=True
),
crystal_size=CrystalSize(x=0, y=0, z=0),
)
with patch("aare.gui.main_window.time.time", return_value=250.0):
win.update_daq_status(status)
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 (
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": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
fake_token = "header.payload.signature"
win = MainWindow(
base_url=None,
token=fake_token,
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr=None,
gonio_cam_addr=None,
gonio_cam_id=None,
)
qtbot.addWidget(win)
win._idle_close_timeout_s = 10
win._last_user_interaction_ts = 100.0
win.close = MagicMock()
with patch("aare.gui.main_window.time.time", return_value=111.0):
win._check_idle_timeout()
win.close.assert_called_once()
def test_idle_timeout_does_not_close_while_automation_active(qtbot, mock_ui_state):
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": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
fake_token = "header.payload.signature"
win = MainWindow(
base_url=None,
token=fake_token,
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr=None,
gonio_cam_addr=None,
gonio_cam_id=None,
)
qtbot.addWidget(win)
win._idle_close_timeout_s = 10
win._last_user_interaction_ts = 100.0
win.job_list_panel.is_running = MagicMock(return_value=True)
win.close = MagicMock()
with patch("aare.gui.main_window.time.time", return_value=111.0):
win._check_idle_timeout()
win.close.assert_not_called()
def test_cleanup_returns_from_portrait_mode(qtbot, mock_ui_state):
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": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
fake_token = "header.payload.signature"
win = MainWindow(
base_url=None,
token=fake_token,
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr=None,
gonio_cam_addr=None,
gonio_cam_id=None,
)
qtbot.addWidget(win)
win.enter_portrait_mode()
assert win.content_stack.currentWidget() is win.portrait_mode_page
win.cleanup()
assert win.content_stack.currentWidget() is win._standard_main_page
def test_cleanup_returns_from_compact_automation_view(qtbot, mock_ui_state):
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": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
fake_token = "header.payload.signature"
win = MainWindow(
base_url=None,
token=fake_token,
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr=None,
gonio_cam_addr=None,
gonio_cam_id=None,
)
qtbot.addWidget(win)
win.enter_compact_automation_view()
assert win.content_stack.currentWidget() is win.compact_automation_page
win.cleanup()
assert win.content_stack.currentWidget() is win._standard_main_page
def _make_window(qtbot):
win = MainWindow(
base_url=None,
token="header.payload.signature",
default_image=None,
zmq_addr=None,
pred_zmq_addr=None,
beamline_cam_addr=None,
gonio_cam_addr=None,
gonio_cam_id=None,
)
qtbot.addWidget(win)
return win
def test_theme_settings_migrate_and_slots_switch(qtbot, mock_ui_state):
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": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
win = _make_window(qtbot)
settings = QSettings("PSI", "AareGUI")
saved = settings.value("appearance/theme")
try:
# Pre-rename tokens saved by older builds must map to the new ones.
settings.setValue("appearance/theme", "portrait")
win._restore_theme_settings()
assert win._theme_mode == THEME_SUNSET
settings.setValue("appearance/theme", "original")
win._restore_theme_settings()
assert win._theme_mode == THEME_SUNRISE
settings.setValue("appearance/theme", THEME_BLUEBIRD)
win._restore_theme_settings()
assert win._theme_mode == THEME_BLUEBIRD
finally:
if saved is None:
settings.remove("appearance/theme")
else:
settings.setValue("appearance/theme", saved)
win.use_bluebird_theme()
assert win._theme_mode == THEME_BLUEBIRD
win.use_portrait_theme() # exercises the sunset palette flip
assert win._theme_mode == THEME_SUNSET
win.use_legacy_theme()
assert win._theme_mode == THEME_SUNRISE
def test_restore_window_state_heals_all_hidden_docks(qtbot, mock_ui_state):
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": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
win = _make_window(qtbot)
for dock in win.findChildren(QDockWidget):
dock.hide()
assert all(d.isHidden() for d in win.findChildren(QDockWidget))
# state_manager is mocked, so restore_window is a no-op and the
# all-hidden layout survives to the heal check.
win._restore_window_state()
assert not win.tell_samples_dock.isHidden()
def test_close_restores_pre_watch_layout(qtbot, mock_ui_state):
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": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
win = _make_window(qtbot)
pre_watch = win.saveState()
for dock in win.findChildren(QDockWidget):
dock.hide()
win._session_operations_enabled = False
win._pre_watch_dock_state = pre_watch
win.close()
# 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"