CI / test-with-coverage (pull_request) Successful in 2m23s
CI / lint (pull_request) Successful in 47s
CI / test (3.12) (pull_request) Successful in 58s
CI / test (3.13) (pull_request) Successful in 1m3s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Successful in 1m6s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Successful in 1m6s
CI / test (3.14) (pull_request) Successful in 2m18s
CI / coverage-analysis (pull_request) Successful in 3s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Successful in 3m27s
Docs build and publish / docker (push) Successful in 4s
CI / lint (push) Canceled after 34s
CI / test (3.12) (push) Canceled after 34s
CI / test (3.13) (push) Canceled after 29s
CI / test (3.14) (push) Canceled after 29s
CI / test-with-beamline-plugins (pxi_bec) (push) Canceled after 24s
CI / test-with-beamline-plugins (pxii_bec) (push) Canceled after 24s
CI / test-with-beamline-plugins (pxiii_bec) (push) Canceled after 19s
CI / test-with-coverage (push) Canceled after 19s
CI / coverage-analysis (push) Canceled after 0s
Build and Publish / release (push) Successful in 15s
292 lines
10 KiB
Python
292 lines
10 KiB
Python
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()
|