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, state: BeamlineStateEnum = BeamlineStateEnum.SampleAlignment, ) -> 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=state, 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_help_overlay_folds_when_mouse_leaves_box(camera, qtbot): camera.grab() # paint records the collapsed "?" badge hit rect badge = camera._help_hit_rect qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center().toPoint()) assert camera._help_expanded camera.grab() # expanded paint records the full box rect box = camera._help_hit_rect _mouse_move(camera, box.center().toPoint()) # inside the box: stays open assert camera._help_expanded _mouse_move(camera, QPoint(int(box.left()) - 40, int(box.bottom()) + 40)) assert not camera._help_expanded, "moving off the box must fold it back to the badge" # Leaving the widget entirely folds it too. camera.grab() badge = camera._help_hit_rect qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center().toPoint()) assert camera._help_expanded camera.leaveEvent(QEvent(QEvent.Type.Leave)) 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, state=BeamlineStateEnum.DataCollection ) ) style = camera._busy_overlay_style assert style is not None assert style.text == "BEAMLINE BUSY" camera.grab() assert camera._session_badge_rect is None # Sample alignment is the exception: its busy moves ARE the alignment, # watched in this very view — no BEAMLINE BUSY curtain over it. camera.update_daq_status( _status( busy=True, session=SessionsStateEnum.OwnedByYou, state=BeamlineStateEnum.SampleAlignment ) ) assert camera._busy_overlay_style 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.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()) def test_help_more_link_opens_full_dialog(camera, qtbot): camera.grab() # paint records the "?" badge hit rect badge = camera._help_hit_rect qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=badge.center().toPoint()) assert camera._help_expanded camera.grab() # expanded overlay paint records the More... link rect more = camera._help_more_rect assert more is not None opened = [] camera.open_full_help.connect(lambda: opened.append(True)) qtbot.mouseClick(camera.viewport(), Qt.MouseButton.LeftButton, pos=more.center().toPoint()) assert opened, "More... must open the full F1 dialog" assert not camera._help_expanded # cheatsheet folds behind the dialog def _wheel(widget, *, x=0, y=0, modifiers=Qt.KeyboardModifier.NoModifier): from PySide6.QtGui import QWheelEvent return QWheelEvent( QPointF(5, 5), QPointF(widget.mapToGlobal(QPoint(5, 5))), QPoint(0, 0), QPoint(x, y), Qt.MouseButton.NoButton, modifiers, Qt.ScrollPhase.NoScrollPhase, False, ) def test_alt_wheel_axis_swap_still_changes_exposure(camera): """xcb/windows swap wheel axes while Alt is held: the scroll lands in angleDelta().x(). The fallback must still reach the exposure branch — and a genuinely empty wheel event must do nothing.""" camera.update_daq_status(_status(busy=False, session=SessionsStateEnum.OwnedByYou)) sent = [] camera.samcam_updated.connect(sent.append) camera.wheelEvent(_wheel(camera, x=-120, modifiers=Qt.KeyboardModifier.AltModifier)) assert sent, "Alt+wheel with x-only delta must still adjust exposure" assert sent[0].exposure < 0.02, "negative delta must decrease exposure" camera.wheel_event_timer.stop() # bypass the throttle for the second event n = len(sent) camera.wheelEvent(_wheel(camera)) # zero delta: ignored assert len(sent) == n def test_hover_hud_coords_and_scale_bar(camera): # Hover inside the image: bottom-right HUD paints coords + grey scale bar. _mouse_move(camera, QPoint(400, 300)) assert camera._hover_pos is not None camera.grab() # Hover outside the image (negative scene coords): HUD skips drawing. _mouse_move(camera, QPoint(-10, -10)) camera.grab() # Leaving the view clears the readout entirely. camera.leaveEvent(QEvent(QEvent.Type.Leave)) assert camera._hover_pos is None camera.grab() def test_scale_bar_nice_numbers(): # 1-2-5 progression against the ~120 px target, and the mm label swap. assert SampleCameraImageLabel._scale_bar(1.0) == (100.0, "100 µm") assert SampleCameraImageLabel._scale_bar(2.0) == (200.0, "200 µm") assert SampleCameraImageLabel._scale_bar(5.0) == (500.0, "500 µm") assert SampleCameraImageLabel._scale_bar(10.0) == (1000.0, "1 mm") def test_autoscale_fits_from_the_first_frame(camera): from PySide6.QtGui import QPixmap # Fit-to-view is the default; a frame whose size differs from the fitted # one (here: the 2000x2000 startup placeholder) must refit immediately, # not wait for the next view resize. assert camera._autoscale camera.update_pixmap(QPixmap(4000, 4000)) assert camera.transform().m11() < 1.0 # The right-click toggle still restores 1:1. camera._autoscale = False camera._scaling() assert camera.transform().isIdentity() def test_unknown_session_paints_the_viewing_mode_badge(camera): # No status yet: the busy overlay has nothing, the session overlay must # still hand out the same click target as the known not-owned states. assert camera._busy_overlay_style is None camera.grab() assert camera._session_badge_rect is not None