A "Currently mounted: <sample_name>" HUD line in the top-left corner of the sample camera, driven by the DAQ status (clears when nothing is mounted). Painted like the other camera HUDs and sized through the text zoom, which QSS cannot reach here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
327 lines
11 KiB
Python
327 lines
11 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()
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_mounted_sample_hud_follows_status(camera):
|
|
from aarecommon.models.models import DewarAddress, SampleShortInfo
|
|
|
|
# No sample in the status -> no HUD line, draw path skips cleanly.
|
|
assert camera._mounted_sample_name is None
|
|
camera.grab()
|
|
|
|
sample = SampleShortInfo(
|
|
db_id=7,
|
|
puck_name="puck1",
|
|
dewar_name="dewar",
|
|
sample_name="lyso_007",
|
|
run_number=1,
|
|
user="p123",
|
|
pin=1,
|
|
location=DewarAddress(segment="A", pos=1),
|
|
)
|
|
status = _status(busy=False, session=SessionsStateEnum.OwnedByYou)
|
|
camera.update_daq_status(status.model_copy(update={"sample": sample}))
|
|
assert camera._mounted_sample_name == "lyso_007"
|
|
camera.grab() # paints the top-left "Currently mounted:" line
|
|
|
|
# Unmount (sample gone from the status) clears the line again.
|
|
camera.update_daq_status(status)
|
|
assert camera._mounted_sample_name is None
|