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
This commit is contained in:
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user