DAQ/GUI updated: gui session handling in daq and gui so staff can now close erroneous GUIs and see who has the baton. Added tests
Build and Publish / test (push) Successful in 1m26s
Build and Publish / build (push) Successful in 15s
Build and Publish / Build and Deploy Docs (push) Successful in 38s

This commit is contained in:
2026-04-30 14:03:10 +02:00
parent 8cd26d54b7
commit 947e142f6e
9 changed files with 420 additions and 79 deletions
+1
View File
@@ -748,6 +748,7 @@ class OpenGuiSessionInfo(BaseModel):
close_requested_by: str | None = None
close_requested_at: float | None = None
close_grace_seconds: int | None = None
holds_baton: bool = False
class CrystalSize(BaseModel):
+49 -18
View File
@@ -108,6 +108,29 @@ class BeamlineConfig:
def _gui_session_key(self, session: int) -> str:
return f"{self.__bl}:gui_session:{session}"
def _write_gui_session(
self,
payload: OpenGuiSessionInfo,
expiry_sec: int | None = None,
) -> None:
expiry = int(expiry_sec or self.GUI_SESSION_EXPIRE_SECONDS)
pipe = self.__client.pipeline()
pipe.set(self._gui_session_key(payload.session), payload.model_dump_json())
pipe.expire(self._gui_session_key(payload.session), expiry)
pipe.sadd(self._gui_sessions_index_key(), payload.session)
pipe.execute()
def _current_gui_session_ttl(self, session: int) -> int | None:
try:
ttl = int(self.__client.ttl(self._gui_session_key(session)))
except Exception:
return None
if ttl > 0:
return ttl
return None
def _read_gui_session(self, session: int) -> OpenGuiSessionInfo | None:
raw = self.__client.get(self._gui_session_key(session))
if raw is None:
@@ -137,27 +160,22 @@ class BeamlineConfig:
close_requested_by=existing.close_requested_by if existing is not None else None,
close_requested_at=existing.close_requested_at if existing is not None else None,
close_grace_seconds=existing.close_grace_seconds if existing is not None else None,
holds_baton=existing.holds_baton if existing is not None else False,
)
pipe = self.__client.pipeline()
pipe.set(self._gui_session_key(session), payload.model_dump_json())
pipe.expire(self._gui_session_key(session), expiry_sec)
pipe.sadd(self._gui_sessions_index_key(), session)
pipe.execute()
self._write_gui_session(payload, expiry_sec=expiry_sec)
self.purge_expired_gui_sessions()
return payload
def update_gui_interaction(self, session: int, last_interaction_ts: float) -> OpenGuiSessionInfo | None:
payload = self._read_gui_session(session)
if payload is None:
self.__client.srem(self._gui_sessions_index_key(), session)
return None
payload.last_interaction_ts = last_interaction_ts
self.__client.set(self._gui_session_key(session), payload.model_dump_json())
ttl = self.__client.ttl(self._gui_session_key(session))
if ttl and ttl > 0:
self.__client.expire(self._gui_session_key(session), ttl)
ttl = self._current_gui_session_ttl(session)
self._write_gui_session(payload, expiry_sec=ttl)
return payload
def request_gui_close(
@@ -169,6 +187,7 @@ class BeamlineConfig:
) -> OpenGuiSessionInfo | None:
payload = self._read_gui_session(session)
if payload is None:
self.__client.srem(self._gui_sessions_index_key(), session)
return None
payload.close_requested = True
@@ -176,15 +195,14 @@ class BeamlineConfig:
payload.close_requested_at = time.time()
payload.close_grace_seconds = grace_seconds
self.__client.set(self._gui_session_key(session), payload.model_dump_json())
ttl = self.__client.ttl(self._gui_session_key(session))
if ttl and ttl > 0:
self.__client.expire(self._gui_session_key(session), ttl)
ttl = self._current_gui_session_ttl(session)
self._write_gui_session(payload, expiry_sec=ttl)
return payload
def clear_gui_close_request(self, session: int) -> None:
payload = self._read_gui_session(session)
if payload is None:
self.__client.srem(self._gui_sessions_index_key(), session)
return
payload.close_requested = False
@@ -192,16 +210,15 @@ class BeamlineConfig:
payload.close_requested_at = None
payload.close_grace_seconds = None
self.__client.set(self._gui_session_key(session), payload.model_dump_json())
ttl = self.__client.ttl(self._gui_session_key(session))
if ttl and ttl > 0:
self.__client.expire(self._gui_session_key(session), ttl)
ttl = self._current_gui_session_ttl(session)
self._write_gui_session(payload, expiry_sec=ttl)
def remove_gui_session(self, session: int) -> None:
pipe = self.__client.pipeline()
pipe.delete(self._gui_session_key(session))
pipe.srem(self._gui_sessions_index_key(), session)
pipe.execute()
self.purge_expired_gui_sessions()
def purge_expired_gui_sessions(self) -> None:
session_ids = self.__client.smembers(self._gui_sessions_index_key())
@@ -223,14 +240,28 @@ class BeamlineConfig:
if not session_ids:
return []
holder = self.baton_holder
holder_session = holder.session if holder is not None else None
sessions: list[OpenGuiSessionInfo] = []
for session_id in sorted((int(s) for s in session_ids)):
payload = self._read_gui_session(session_id)
if payload is not None:
payload.holds_baton = (payload.session == holder_session)
sessions.append(payload)
return sessions
def get_gui_session(self, session: int) -> OpenGuiSessionInfo | None:
payload = self._read_gui_session(session)
if payload is None:
self.__client.srem(self._gui_sessions_index_key(), session)
return None
holder = self.baton_holder
payload.holds_baton = bool(holder is not None and holder.session == payload.session)
return payload
# Session and authentication management
@property
+39 -4
View File
@@ -318,7 +318,7 @@ async def status(token: str = Depends(oauth2_scheme)) -> DAQStatusModel:
session=data.session,
username=data.sub,
staff=data.staff,
expiry_sec=auth.SESSION_EXPIRE_SECONDS,
expiry_sec=cfg.GUI_SESSION_EXPIRE_SECONDS,
)
full = daq.status
@@ -339,9 +339,14 @@ async def status(token: str = Depends(oauth2_scheme)) -> DAQStatusModel:
session=cfg.session_state(data.session),
staff=data.staff
)
full.open_guis = cfg.get_open_gui_sessions() if data.staff else []
return full
if data.staff:
full.open_guis = cfg.get_open_gui_sessions()
else:
own_gui = cfg.get_gui_session(data.session)
full.open_guis = [own_gui] if own_gui is not None else []
return full
@app.get("/beamline/geometry")
async def sample_geometry(
token: str = Depends(oauth2_scheme),
@@ -1524,6 +1529,30 @@ async def request_gui_close(
}
@app.delete("/admin/gui_sessions/{session_id}")
async def force_remove_gui_session(
session_id: int,
token: str = Depends(oauth2_scheme),
) -> dict:
"""
Staff-only hard removal of a GUI session from Redis.
This is intended for stuck/crashed GUIs and clears:
- GUI presence entry
- active session/baton if the same session holds it
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
cfg.end_active_session(session_id)
cfg.remove_gui_session(session_id)
return {
"ok": True,
"session": session_id,
"message": "GUI session force removed.",
}
@app.post("/admin/gui_sessions/{session_id}/interaction")
async def update_gui_interaction(
session_id: int,
@@ -1540,7 +1569,13 @@ async def update_gui_interaction(
detail="Cannot update interaction for another session.",
)
cfg.update_gui_interaction(session=session_id, last_interaction_ts=time.time())
payload = cfg.update_gui_interaction(session=session_id, last_interaction_ts=time.time())
if payload is None:
raise HTTPException(
status_code=api_status.HTTP_404_NOT_FOUND,
detail="GUI session not found.",
)
return {"ok": True}
@app.post("/access/end_session")
+13 -4
View File
@@ -1088,6 +1088,9 @@ class MainWindow(QMainWindow):
def update_daq_status(self, s: DAQStatusModel):
self._latest_daq_status = s
if self._is_automation_active():
self._refresh_idle_activity(report_backend=False)
if hasattr(self, "beamline_camera_thread") and self.beamline_camera_thread is not None:
self.beamline_camera_thread.set_busy(s.busy)
@@ -1361,11 +1364,11 @@ class MainWindow(QMainWindow):
except Exception as e:
logger.warning(f"Failed to save main window state: {e}")
# Release baton before closing
# End session before closing so the backend removes this GUI from Redis immediately
try:
self.daq.release_baton_on_close()
self.daq.end_session_on_close()
except Exception as e:
logger.warning(f"Failed to release baton on close: {e}")
logger.warning(f"Failed to end session on close: {e}")
try:
self.cleanup()
@@ -1435,9 +1438,12 @@ class MainWindow(QMainWindow):
return False
def _mark_user_interaction(self) -> None:
def _refresh_idle_activity(self, *, report_backend: bool = False) -> None:
self._last_user_interaction_ts = time.time()
if not report_backend:
return
now = time.monotonic()
if now - self._last_interaction_report_ts < self._interaction_report_min_interval_s:
return
@@ -1448,6 +1454,9 @@ class MainWindow(QMainWindow):
except Exception as e:
logger.debug(f"Failed to report GUI interaction: {e}")
def _mark_user_interaction(self) -> None:
self._refresh_idle_activity(report_backend=True)
def eventFilter(self, obj, event):
try:
if event is not None and event.type() in {
+31 -2
View File
@@ -251,9 +251,9 @@ class DeveloperHelpDialog(QDialog):
if self._is_staff:
self._gui_sessions_table = QTableWidget(self)
self._gui_sessions_table.setColumnCount(7)
self._gui_sessions_table.setColumnCount(8)
self._gui_sessions_table.setHorizontalHeaderLabels(
["Session", "User", "Staff", "Last seen", "Last interaction", "Close requested", "Requested by"]
["Session", "User", "Staff", "Baton", "Last seen", "Last interaction", "Close requested", "Requested by"]
)
self._gui_sessions_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self._gui_sessions_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
@@ -272,11 +272,15 @@ class DeveloperHelpDialog(QDialog):
self._gui_request_close_btn = QPushButton("Request Close", self)
self._gui_request_close_btn.clicked.connect(self._request_close_selected_gui)
self._gui_force_remove_btn = QPushButton("Force Remove", self)
self._gui_force_remove_btn.clicked.connect(self._force_remove_selected_gui)
gui_controls = QHBoxLayout()
gui_controls.addWidget(self._gui_refresh_btn)
gui_controls.addWidget(QLabel("Grace:", self))
gui_controls.addWidget(self._gui_close_grace_spin)
gui_controls.addWidget(self._gui_request_close_btn)
gui_controls.addWidget(self._gui_force_remove_btn)
gui_controls.addStretch(1)
gui_container = QWidget(self)
@@ -553,6 +557,7 @@ class DeveloperHelpDialog(QDialog):
str(session.session),
session.username,
"Yes" if session.staff else "No",
"Yes" if session.holds_baton else "No",
self._format_age(session.last_seen_ts),
self._format_age(session.last_interaction_ts),
"Yes" if session.close_requested else "No",
@@ -600,4 +605,28 @@ class DeveloperHelpDialog(QDialog):
return
self._daq.request_gui_close(session_id, grace_seconds)
self._daq.load_gui_sessions()
@Slot()
def _force_remove_selected_gui(self) -> None:
session_id = self._selected_gui_session_id()
if session_id is None:
QMessageBox.information(self, "Active GUIs", "Please select a GUI session first.")
return
answer = QMessageBox.warning(
self,
"Force Remove GUI Session",
(
f"Force remove GUI session {session_id} from Redis?\n\n"
"This is intended for stale/crashed GUIs and may also clear the active baton/session if it belongs "
"to that session."
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if answer != QMessageBox.StandardButton.Yes:
return
self._daq.force_remove_gui_session(session_id)
self._daq.load_gui_sessions()
+43 -5
View File
@@ -1655,18 +1655,31 @@ class DAQWorker(QObject):
self.http_error.emit(str(e))
def release_baton_on_close(self):
"""Release baton when GUI is closed to free the beamline."""
"""Backward-compatible fallback."""
self.end_session_on_close()
def end_session_on_close(self):
"""
End the GUI session when the window is closed.
This is preferred over only releasing the baton because the backend can:
- remove the Redis GUI session entry immediately
- clear the active beamline session if this GUI owns it
"""
try:
if hasattr(self, "_baton_timeout_timer") and self._baton_timeout_timer is not None:
self._baton_timeout_timer.stop()
self.release_baton()
self.end_session()
from PySide6.QtCore import QEventLoop, QTimer
loop = QEventLoop()
QTimer.singleShot(500, loop.quit)
loop.exec()
logger.info("Baton release requested on GUI close")
logger.info("End session requested on GUI close")
except Exception as e:
logger.warning(f"Error releasing baton on close: {e}")
logger.warning(f"Error ending session on close: {e}")
# ─────────────────────────────────────────────
# Workflow API methods
@@ -1870,7 +1883,32 @@ class DAQWorker(QObject):
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
request.setRawHeader(b"Content-Type", b"application/json")
reply = self.__net_manager.post(request, QByteArray(b""))
reply.finished.connect(lambda: self.handle_req_response(reply))
reply.finished.connect(lambda: self._handle_gui_session_mutation_response(reply))
@Slot(int)
def force_remove_gui_session(self, session_id: int):
if self.__base_url is None:
logger.info(f"DELETE /admin/gui_sessions/{session_id}")
return
request = QNetworkRequest(QUrl(f"{self.__base_url}/admin/gui_sessions/{session_id}"))
request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8"))
reply = self.__net_manager.deleteResource(request)
reply.finished.connect(lambda: self._handle_gui_session_mutation_response(reply))
def _handle_gui_session_mutation_response(self, reply: QNetworkReply):
try:
if reply.error() != QNetworkReply.NetworkError.NoError:
self.handle_req_response(reply)
self.load_gui_sessions()
return
_ = self.handle_response(reply)
self.load_gui_sessions()
except Exception as e:
logger.error(f"GUI session mutation failed: {e}")
self.http_error.emit(str(e))
self.load_gui_sessions()
@Slot(int)
def report_gui_interaction(self, session_id: int):
+52 -3
View File
@@ -1,16 +1,18 @@
import os
import sys
import types
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from PySide6.QtWidgets import QApplication
from fastapi.testclient import TestClient
from aare.common.models import SampleShortInfo, DewarAddress
# Ensure safe defaults during tests
os.environ.setdefault("BEAMLINE", "SIMULATED")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("JWT_AAREDAQ_KEY", "test_key_for_unit_testing")
# -------------------------
# Qt app fixture
@@ -34,4 +36,51 @@ def sample_info():
user="group1",
pin=3,
location=DewarAddress(segment="A", pos=2),
)
)
# -------------------------
# Shared DAQ server fixtures
# -------------------------
@pytest.fixture
def mock_backend():
import aare.daq.server as server
with patch("aare.daq.server.daq") as m_daq, \
patch("aare.daq.server.bl") as m_bl, \
patch("aare.daq.server.cfg") as m_cfg:
server.daq = m_daq
server.bl = m_bl
server.cfg = m_cfg
m_daq.busy = False
m_cfg.pgroup = "p12345"
m_cfg.session_state.return_value = "ACTIVE"
yield {
"daq": m_daq,
"bl": m_bl,
"cfg": m_cfg,
}
@pytest.fixture
def client(mock_backend):
import aare.daq.server as server
with patch("aare.daq.auth.parse_token") as mock_parse:
from aare.daq.auth import TokenData
mock_parse.return_value = TokenData(
sub="testuser",
staff=True,
pgroups=["p12345"],
session=123,
)
with patch("cv2.imencode") as mock_imencode:
mock_imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
with TestClient(server.app) as c:
yield c
+4 -43
View File
@@ -1,6 +1,5 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch, PropertyMock
from unittest.mock import patch, PropertyMock
import os
import io
import cv2
@@ -20,44 +19,6 @@ with patch("aare.daq.daq.AareDAQ"), \
from aare.daq.server import app
import aare.daq.server as server
@pytest.fixture(autouse=True)
def mock_backend():
with patch("aare.daq.server.daq") as m_daq, \
patch("aare.daq.server.bl") as m_bl, \
patch("aare.daq.server.cfg") as m_cfg:
# Manually ensure these globals are set to our mocks
server.daq = m_daq
server.bl = m_bl
server.cfg = m_cfg
m_daq.busy = False
m_cfg.pgroup = "p12345"
m_cfg.session_state.return_value = "ACTIVE"
yield {
"daq": m_daq,
"bl": m_bl,
"cfg": m_cfg
}
@pytest.fixture
def client(mock_backend):
# Mock auth.parse_token to return a valid TokenData
with patch("aare.daq.auth.parse_token") as mock_parse:
from aare.daq.auth import TokenData
mock_parse.return_value = TokenData(
sub="testuser",
staff=True,
pgroups=["p12345"],
session=123,
)
# Also mock cv2.imencode globally within the client context to avoid OpenCV issues with mocks
with patch("cv2.imencode") as mock_imencode:
mock_imencode.return_value = (True, np.array([1, 2, 3], dtype=np.uint8))
with TestClient(app) as c:
yield c
def test_meta_error_codes(client):
response = client.get("/meta/error-codes")
assert response.status_code == 200
@@ -67,10 +28,10 @@ def test_meta_error_codes(client):
def test_status(client, mock_backend):
mock_daq = mock_backend["daq"]
mock_cfg = mock_backend["cfg"]
from aare.common.models import DAQStatusModel, SessionStatus, SessionsStateEnum, BeamlineStateEnum, SampleGeometryModel, BeamlineStatus, CrystalSize, SampleCameraSettings
from aare.common.diffraction_geometry import DiffractionGeometry
# Create a valid status object
from aare.common.coordinate import Coordinate, SmargonCoordinate
geom = SampleGeometryModel(
@@ -161,4 +122,4 @@ def test_get_image(client, mock_backend):
response = client.get("/beamline/image", headers={"Authorization": "Bearer fake-token"})
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
assert len(response.content) > 0
assert len(response.content) > 0
+188
View File
@@ -72,3 +72,191 @@ def test_main_window_mount_view(qtbot, mock_ui_state):
win.mount_view()
# This just changes some panel visibility, hard to assert without deep inspection
# but we check it doesn't crash
def test_mark_user_interaction_reports_backend(qtbot, mock_ui_state):
with patch("requests.get"), \
patch("aare.gui.main_window.DAQWorker") as mock_daq_cls, \
patch("aare.gui.main_window.SampleCameraThread"), \
patch("aare.gui.main_window.PredictionSubscriber"), \
patch("aare.gui.main_window.VideoThread"), \
patch("aare.gui.main_window.JFJochDBusClient"), \
patch("aare.gui.main_window.WorkflowSSEClient"), \
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.SampleCameraThread"), \
patch("aare.gui.main_window.PredictionSubscriber"), \
patch("aare.gui.main_window.VideoThread"), \
patch("aare.gui.main_window.JFJochDBusClient"), \
patch("aare.gui.main_window.WorkflowSSEClient"), \
patch("aare.gui.main_window.jwt.decode") as mock_jwt:
from aare.common.models import (
DAQStatusModel,
SessionStatus,
BeamlineStatus,
SampleCameraSettings,
CrystalSize,
SessionsStateEnum,
BeamlineStateEnum,
)
from aare.common.coordinate import Coordinate, SmargonCoordinate
from aare.common.diffraction_geometry import DiffractionGeometry
from aare.common.sample_geometry import SampleGeometryModel
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
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.SampleCameraThread"), \
patch("aare.gui.main_window.PredictionSubscriber"), \
patch("aare.gui.main_window.VideoThread"), \
patch("aare.gui.main_window.JFJochDBusClient"), \
patch("aare.gui.main_window.WorkflowSSEClient"), \
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.SampleCameraThread"), \
patch("aare.gui.main_window.PredictionSubscriber"), \
patch("aare.gui.main_window.VideoThread"), \
patch("aare.gui.main_window.JFJochDBusClient"), \
patch("aare.gui.main_window.WorkflowSSEClient"), \
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()