From 7b4764e7caa336e2b26165cc3cbd43f6afed86f7 Mon Sep 17 00:00:00 2001 From: Dawn Date: Mon, 10 Aug 2026 10:56:17 +0200 Subject: [PATCH] test: cover the camera overlays, theme migration, layout heal, and splash Diff coverage vs main was 77%, under the 80% CI gate; the new camera help/error/badge code and the theme+layout main_window paths were the uncovered bulk. Locally: 82%. Co-Authored-By: Claude Fable 5 --- tests/unit/gui/test_camera_image.py | 162 +++++++++++++++++++++++++++ tests/unit/gui/test_main_window.py | 122 ++++++++++++++++++++ tests/unit/gui/test_splash_screen.py | 14 +++ 3 files changed, 298 insertions(+) create mode 100644 tests/unit/gui/test_camera_image.py create mode 100644 tests/unit/gui/test_splash_screen.py diff --git a/tests/unit/gui/test_camera_image.py b/tests/unit/gui/test_camera_image.py new file mode 100644 index 00000000..3dd977f0 --- /dev/null +++ b/tests/unit/gui/test_camera_image.py @@ -0,0 +1,162 @@ +import pytest +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, +) +from PySide6.QtCore import QEvent, QPoint, QPointF, Qt +from PySide6.QtGui import QMouseEvent + +from aare.gui.scan_logic.raster_grid_manager import RasterGridManager +from aare.gui.styles import THEME_SUNRISE, THEME_SUNSET +from aare.gui.widgets.camera_image import SampleCameraImageLabel + + +def _geom() -> SampleGeometryModel: + return 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), + ) + + +def _status(*, busy: bool, session: SessionsStateEnum) -> DAQStatusModel: + return DAQStatusModel( + geom=_geom(), + 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=busy, + session=SessionStatus(session=session, current_pgroup="p123", staff=True), + crystal_size=CrystalSize(x=0, y=0, z=0), + ) + + +def _mouse_move(widget, pos: QPoint) -> None: + # qtbot.mouseMove drives the real cursor, which the offscreen platform + # ignores — deliver the move event directly instead. + event = QMouseEvent( + QEvent.Type.MouseMove, + QPointF(pos), + QPointF(widget.mapToGlobal(pos)), + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + widget.mouseMoveEvent(event) + + +@pytest.fixture +def camera(qtbot): + geom = _geom() + label = SampleCameraImageLabel(geom=geom, raster=RasterGridManager(geom), default_image=None) + qtbot.addWidget(label) + label.resize(800, 600) + return label + + +def test_help_badge_click_toggles_cheatsheet(camera, qtbot): + camera.grab() # paint records the collapsed "?" badge hit rect + badge = camera._help_hit_rect + assert badge is not None + assert not camera._help_expanded + + qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center().toPoint()) + assert camera._help_expanded + + camera.grab() # expanded overlay: hit rect grows to the whole cheatsheet box + box = camera._help_hit_rect + assert box is not None + assert box.height() > badge.height() + + qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=box.center().toPoint()) + assert not camera._help_expanded + + +def test_camera_error_message_rewords_and_draws(camera): + camera.set_camera_available(False) + camera.set_camera_error_message("Sample camera feed unavailable: cable unplugged") + assert camera._camera_error_message == ( + "Sample camera feed unavailable because cable unplugged" + ) + camera.grab() # exercises the bottom-center unavailable overlay text path + + camera.set_camera_available(True) + assert camera._camera_error_message is None + + +def test_busy_warning_is_not_a_click_target(camera): + camera.update_daq_status(_status(busy=True, session=SessionsStateEnum.OwnedByYou)) + style = camera._busy_overlay_style + assert style is not None + assert style.text == "BEAMLINE BUSY" + camera.grab() + assert camera._session_badge_rect is None + + +def test_vacant_badge_hover_click_and_theme(camera, qtbot): + camera.update_daq_status(_status(busy=False, session=SessionsStateEnum.Vacant)) + style = camera._busy_overlay_style + assert style is not None + assert style.text == "In viewing mode" + assert style.subtext # the grab-baton hint line + + camera.grab() # paint records the badge rect + badge = camera._session_badge_rect + assert badge is not None + + _mouse_move(camera, badge.center()) + assert camera._session_badge_hovered + camera.grab() # hover fill, light-theme darken branch + + camera.set_theme(THEME_SUNSET) + assert camera._dark_theme + camera.grab() # hover fill, sunset brighten branch + camera.set_theme(THEME_SUNRISE) + assert not camera._dark_theme + + _mouse_move(camera, QPoint(1, 1)) + assert not camera._session_badge_hovered + + _mouse_move(camera, badge.center()) + camera.leaveEvent(QEvent(QEvent.Type.Leave)) + assert not camera._session_badge_hovered + + with qtbot.waitSignal(camera.session_badge_clicked, timeout=1000): + qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center()) diff --git a/tests/unit/gui/test_main_window.py b/tests/unit/gui/test_main_window.py index 701a1d5c..8a0c5046 100644 --- a/tests/unit/gui/test_main_window.py +++ b/tests/unit/gui/test_main_window.py @@ -1,8 +1,11 @@ from unittest.mock import MagicMock, patch import pytest +from PySide6.QtCore import QSettings +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 @@ -362,3 +365,122 @@ def test_cleanup_returns_from_compact_automation_view(qtbot, mock_ui_state): 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() diff --git a/tests/unit/gui/test_splash_screen.py b/tests/unit/gui/test_splash_screen.py new file mode 100644 index 00000000..f701d94a --- /dev/null +++ b/tests/unit/gui/test_splash_screen.py @@ -0,0 +1,14 @@ +from PySide6.QtGui import QPixmap + +from aare.gui.widgets.splash_screen import LoadingSplashScreen + + +def test_splash_progress_and_message(qtbot): + splash = LoadingSplashScreen(QPixmap(200, 100)) + qtbot.addWidget(splash) + + splash.set_progress(42, "Loading panels") + assert splash.progress.value() == 42 + + splash.set_progress(43) # message-less update takes the no-showMessage branch + assert splash.progress.value() == 43