From 947e142f6e9b5e16daace8c41ee0501b6512d135 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Thu, 30 Apr 2026 14:03:10 +0200 Subject: [PATCH] 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 --- src/aare/common/models.py | 1 + src/aare/daq/config.py | 67 +++++-- src/aare/daq/server.py | 43 ++++- src/aare/gui/main_window.py | 17 +- src/aare/gui/panels/developer_help_dialog.py | 33 +++- src/aare/gui/threads/daq_worker.py | 48 ++++- tests/conftest.py | 55 +++++- tests/unit/daq/test_server.py | 47 +---- tests/unit/gui/test_main_window.py | 188 +++++++++++++++++++ 9 files changed, 420 insertions(+), 79 deletions(-) diff --git a/src/aare/common/models.py b/src/aare/common/models.py index c7cb2dd4..39d8d979 100644 --- a/src/aare/common/models.py +++ b/src/aare/common/models.py @@ -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): diff --git a/src/aare/daq/config.py b/src/aare/daq/config.py index ff1c01a8..6b3c661b 100644 --- a/src/aare/daq/config.py +++ b/src/aare/daq/config.py @@ -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 diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index 107a88be..dc6ec6f2 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -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") diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 857c7516..218eb05c 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -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 { diff --git a/src/aare/gui/panels/developer_help_dialog.py b/src/aare/gui/panels/developer_help_dialog.py index d418db78..aa1c1aa2 100644 --- a/src/aare/gui/panels/developer_help_dialog.py +++ b/src/aare/gui/panels/developer_help_dialog.py @@ -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() \ No newline at end of file diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index a05ab3a9..b5b72f13 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -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): diff --git a/tests/conftest.py b/tests/conftest.py index b5e667b9..2728645e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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), - ) \ No newline at end of file + ) + + +# ------------------------- +# 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 \ No newline at end of file diff --git a/tests/unit/daq/test_server.py b/tests/unit/daq/test_server.py index 071dfdac..f8bce82f 100644 --- a/tests/unit/daq/test_server.py +++ b/tests/unit/daq/test_server.py @@ -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 \ No newline at end of file diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index d773d53b..6357a375 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -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() \ No newline at end of file