Files
AareDAQ/tests/unit/gui/test_camera_image.py
T
duan_jandClaude Fable 5 6d09ae4fe3 feat: show the mounted sample name on the camera view
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>
2026-09-14 13:23:32 +02:00

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