CI / lint (pull_request) Successful in 53s
CI / test (3.11) (pull_request) Successful in 58s
CI / test (3.13) (pull_request) Successful in 59s
CI / test (3.12) (pull_request) Successful in 1m3s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Successful in 1m12s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Successful in 1m14s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Successful in 1m18s
CI / test-with-coverage (pull_request) Successful in 1m29s
CI / coverage-analysis (pull_request) Successful in 3s
CI / lint (push) Successful in 34s
Docs build and publish / docker (push) Successful in 17s
CI / test (3.11) (push) Canceled after 37s
CI / test (3.12) (push) Canceled after 36s
CI / test (3.13) (push) Canceled after 32s
CI / test-with-beamline-plugins (pxi_bec) (push) Canceled after 31s
CI / test-with-beamline-plugins (pxii_bec) (push) Canceled after 27s
CI / test-with-beamline-plugins (pxiii_bec) (push) Canceled after 26s
CI / test-with-coverage (push) Canceled after 22s
CI / coverage-analysis (push) Canceled after 0s
Build and Publish / release (push) Successful in 20s
test: update test...
380 lines
14 KiB
Python
380 lines
14 KiB
Python
"""Tests for the data-collection settings override model.
|
|
|
|
Covers DbOverrideLineEdit (database value vs user's value, persistence across
|
|
samples, go-back-to-DB, recover-my-value) and the panel-level Database/My-values
|
|
toggle with the dtz<->resolution coupling that must hold in both modes.
|
|
"""
|
|
|
|
import types
|
|
|
|
import pytest
|
|
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
|
|
from aarecommon.math.diffraction_geometry import DiffractionGeometry
|
|
from aarecommon.models.models import SampleGeometryModel
|
|
from PySide6.QtCore import Qt
|
|
|
|
from aare.gui.panels.data_collection_settings import DataCollectionSettings
|
|
from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel
|
|
from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel
|
|
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
|
|
from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit
|
|
|
|
|
|
def _edit(field: DbOverrideLineEdit, text: str):
|
|
"""Simulate a user typing into and committing a field."""
|
|
field.editor.setText(text)
|
|
field.editor.on_editing_finished()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Widget: DbOverrideLineEdit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_db_override_follows_database_until_edited(qapp):
|
|
w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
|
|
assert w.source() == DbOverrideLineEdit.SOURCE_DB
|
|
assert not w.has_user_value()
|
|
|
|
w.set_db_value(150.0)
|
|
assert w.value == 150.0 # untouched field tracks the database
|
|
|
|
|
|
def test_db_override_user_value_persists_and_recovers(qapp):
|
|
w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
|
|
|
|
_edit(w, "300.00")
|
|
assert w.has_user_value()
|
|
assert w.source() == DbOverrideLineEdit.SOURCE_MINE
|
|
assert w.value == 300.0
|
|
|
|
# A new database value (e.g. next sample) must not clobber the user's value.
|
|
w.set_db_value(120.0)
|
|
assert w.value == 300.0
|
|
|
|
# Go back to the database value, then recover the user's value.
|
|
w.set_source(DbOverrideLineEdit.SOURCE_DB, emit=False)
|
|
assert w.value == 120.0
|
|
w.set_source(DbOverrideLineEdit.SOURCE_MINE, emit=False)
|
|
assert w.value == 300.0
|
|
|
|
|
|
def test_db_override_reset_forgets_user_value(qapp):
|
|
w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
|
|
_edit(w, "300.00")
|
|
w.reset()
|
|
assert w.value == 200.0
|
|
assert w.source() == DbOverrideLineEdit.SOURCE_DB
|
|
assert not w.has_user_value()
|
|
|
|
|
|
def test_db_override_emits_value_changed_on_toggle(qapp):
|
|
w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
|
|
_edit(w, "300.00") # mine = 300, db = 200
|
|
seen = []
|
|
w.valueChanged.connect(lambda v: seen.append(v))
|
|
w.set_source(DbOverrideLineEdit.SOURCE_DB)
|
|
assert seen[-1] == 200.0 # toggling pushes the now-active value downstream
|
|
w.set_source(DbOverrideLineEdit.SOURCE_MINE)
|
|
assert seen[-1] == 300.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Panel: toggle + dtz/resolution coupling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def diffraction():
|
|
return DiffractionGeometry(
|
|
energy_keV=12.0,
|
|
dtz_mm=150.0,
|
|
pixel_size_mm=0.075,
|
|
beam_center_pxl=(1000.0, 1000.0),
|
|
detector_size_pxl=(2000, 2000),
|
|
detector_description="Eiger 16M",
|
|
detector_serial_number="123",
|
|
poni_rot1_rad=0.0,
|
|
poni_rot2_rad=0.0,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def panel(qapp, diffraction):
|
|
return RotationDataCollectionPanel(diffraction=diffraction)
|
|
|
|
|
|
def test_editing_dtz_updates_resolution_and_switches_to_mine(panel, diffraction):
|
|
_edit(panel.dtz_enter, "250.00")
|
|
assert panel._source == DbOverrideLineEdit.SOURCE_MINE
|
|
assert abs(panel.high_res_enter.value - diffraction.resolution_angstrom(250.0)) < 0.01
|
|
|
|
|
|
def test_editing_resolution_updates_dtz(panel, diffraction):
|
|
_edit(panel.high_res_enter, "2.00")
|
|
expected = max(panel.MIN_DTZ, diffraction.calc_dtz_mm(2.0))
|
|
assert abs(panel.dtz_enter.value - expected) < 0.01
|
|
|
|
|
|
def test_coupling_holds_in_database_mode(panel, diffraction):
|
|
# Set a database resolution; dtz database value must stay consistent.
|
|
panel._apply_db_resolution(2.5)
|
|
assert panel._source == DbOverrideLineEdit.SOURCE_DB
|
|
expected_dtz = max(panel.MIN_DTZ, diffraction.calc_dtz_mm(2.5))
|
|
assert abs(panel.dtz_enter.value - expected_dtz) < 0.01
|
|
assert abs(panel.high_res_enter.value - 2.5) < 0.01
|
|
|
|
|
|
def test_toggle_pushes_active_value_downstream(panel):
|
|
emitted = []
|
|
panel.dtz_updated.connect(lambda v: emitted.append(round(v, 2)))
|
|
|
|
_edit(panel.dtz_enter, "250.00") # mine dtz = 250
|
|
mine_dtz = panel.dtz_enter.value
|
|
|
|
panel.set_source(DbOverrideLineEdit.SOURCE_DB)
|
|
db_dtz = panel.dtz_enter.value
|
|
assert emitted[-1] == round(db_dtz, 2) # downstream got the db value
|
|
|
|
panel.set_source(DbOverrideLineEdit.SOURCE_MINE)
|
|
assert panel.dtz_enter.value == mine_dtz # user value recovered
|
|
assert emitted[-1] == round(mine_dtz, 2) # downstream got the user value
|
|
|
|
|
|
def test_user_override_persists_across_samples(panel, diffraction):
|
|
# Sample 1 loads a database resolution.
|
|
panel._sample = types.SimpleNamespace(db_id=1)
|
|
panel._params = types.SimpleNamespace(
|
|
targetresolution=2.5, transmission=0.5, totalrange=180.0, oscillation=0.1, exposure=0.02
|
|
)
|
|
panel.update_data_collection_parameters()
|
|
|
|
# User overrides the exposure time.
|
|
_edit(panel.image_time_enter, "0.0500")
|
|
assert panel.image_time_enter.value == 0.05
|
|
|
|
# Sample 2 arrives with a different database exposure.
|
|
panel._sample = types.SimpleNamespace(db_id=2)
|
|
panel._params = types.SimpleNamespace(
|
|
targetresolution=1.8, transmission=1.0, totalrange=360.0, oscillation=0.2, exposure=0.01
|
|
)
|
|
panel.update_data_collection_parameters()
|
|
|
|
# In My-values mode the user's exposure persists across the sample change.
|
|
assert panel.image_time_enter.value == 0.05
|
|
# Going back to Database shows the new sample's database value.
|
|
panel.set_source(DbOverrideLineEdit.SOURCE_DB)
|
|
assert panel.image_time_enter.value == 0.01
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Raster grid element size follows the same override model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def geom():
|
|
return SampleGeometryModel(
|
|
beam_location_pxl=Coordinate(x=500, y=500),
|
|
pixel_in_mm=0.001,
|
|
aerotech=Coordinate(x=0, y=0, z=0),
|
|
aerotech_meas=Coordinate(x=0, y=0, z=0),
|
|
smargon=SmargonCoordinate(sh_mm=Coordinate(x=0, y=0, z=0), phi_deg=0.0, chi_deg=0.0),
|
|
omega_deg=0.0,
|
|
beam_size_mm=Coordinate(x=0.01, y=0.01),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def raster_panel(qapp, diffraction, geom):
|
|
mgr = RasterGridManager(geom)
|
|
return RasterDataCollectionPanel(raster_mgr=mgr, diffraction=diffraction)
|
|
|
|
|
|
def test_grid_element_size_user_value_persists_over_geometry(raster_panel):
|
|
emitted = []
|
|
raster_panel.grid_size_updated.connect(lambda x, y: emitted.append((x, y)))
|
|
|
|
_edit(raster_panel.width_enter, "20")
|
|
assert raster_panel._source == DbOverrideLineEdit.SOURCE_MINE
|
|
assert raster_panel.width_enter.value == 20.0
|
|
assert emitted # editing pushed the new element size downstream
|
|
|
|
# The live grid geometry keeps reporting a different element size; the
|
|
# user's value must persist (only the database side changes).
|
|
raster_panel.width_enter.set_db_value(35.0)
|
|
assert raster_panel.width_enter.value == 20.0
|
|
|
|
# Revert to the geometry value, then recover the user's value.
|
|
raster_panel.set_source(DbOverrideLineEdit.SOURCE_DB)
|
|
assert raster_panel.width_enter.value == 35.0
|
|
raster_panel.set_source(DbOverrideLineEdit.SOURCE_MINE)
|
|
assert raster_panel.width_enter.value == 20.0
|
|
|
|
|
|
def test_grid_size_field_shares_panel_toggle(raster_panel):
|
|
# Editing the resolution flips the whole panel to "My values", which the
|
|
# grid-size field shares.
|
|
_edit(raster_panel.high_res_enter, "2.50")
|
|
assert raster_panel._source == DbOverrideLineEdit.SOURCE_MINE
|
|
assert raster_panel.width_enter.source() == DbOverrideLineEdit.SOURCE_MINE
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DataCollectionSettings: per-tab grid button + auto-center after mount
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def settings_panel(qapp, diffraction, geom):
|
|
return DataCollectionSettings(geom, RasterGridManager(geom), diffraction)
|
|
|
|
|
|
def test_grid_button_only_on_raster_tab(settings_panel):
|
|
assert not settings_panel.bounding_box.isHidden() # Raster is the default tab
|
|
for idx in (1, 2, 3):
|
|
settings_panel._tab_bar.setCurrentIndex(idx)
|
|
assert settings_panel.bounding_box.isHidden()
|
|
settings_panel._tab_bar.setCurrentIndex(0)
|
|
assert not settings_panel.bounding_box.isHidden()
|
|
|
|
|
|
def test_auto_center_fires_on_mount_only_when_armed(settings_panel):
|
|
clicks = []
|
|
settings_panel.find_tip.clicked.connect(lambda: clicks.append(1))
|
|
settings_panel.auto_center_after_mount.setChecked(True)
|
|
|
|
# First status with a sample already mounted = GUI (re)start: hardware
|
|
# must not move, only the id is recorded.
|
|
settings_panel._track_sample(1)
|
|
assert not clicks
|
|
|
|
# Real mount after an unmount fires the centering.
|
|
settings_panel._track_sample(None)
|
|
settings_panel._track_sample(2)
|
|
assert len(clicks) == 1
|
|
|
|
# Direct sample exchange (no unmount tick in between) also fires.
|
|
settings_panel._track_sample(3)
|
|
assert len(clicks) == 2
|
|
|
|
# Remounting the SAME sample counts as a fresh mount.
|
|
settings_panel._track_sample(None)
|
|
settings_panel._track_sample(3)
|
|
assert len(clicks) == 3
|
|
|
|
# Disarmed: mounts no longer trigger.
|
|
settings_panel.auto_center_after_mount.setChecked(False)
|
|
settings_panel._track_sample(None)
|
|
settings_panel._track_sample(4)
|
|
assert len(clicks) == 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pending color while typing (movestate property, committed on Enter/focus-out)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _type(qtbot, box, text):
|
|
box.clear()
|
|
qtbot.keyClicks(box, text)
|
|
|
|
|
|
@pytest.fixture
|
|
def tracked_box(qtbot):
|
|
box = NumberLineEdit(-720, 720, default=100.0, decimals=2, track_pending=True)
|
|
qtbot.addWidget(box)
|
|
return box
|
|
|
|
|
|
def test_typing_marks_pending_and_enter_commits(tracked_box, qtbot):
|
|
seen = []
|
|
tracked_box.newValue.connect(seen.append)
|
|
_type(qtbot, tracked_box, "300")
|
|
assert tracked_box.property("movestate") == "pending"
|
|
assert not seen # nothing is applied until Enter/focus-out
|
|
qtbot.keyClick(tracked_box, Qt.Key.Key_Return)
|
|
assert seen == [300.0]
|
|
assert tracked_box.property("movestate") == ""
|
|
|
|
|
|
def test_typing_back_the_applied_value_cancels_pending(tracked_box, qtbot):
|
|
_type(qtbot, tracked_box, "300")
|
|
qtbot.keyClick(tracked_box, Qt.Key.Key_Return)
|
|
_type(qtbot, tracked_box, "300") # same as what is applied now
|
|
assert tracked_box.property("movestate") == ""
|
|
|
|
|
|
def test_incomplete_entry_is_pending(tracked_box, qtbot):
|
|
_type(qtbot, tracked_box, "-") # not a number (yet)
|
|
assert tracked_box.property("movestate") == "pending"
|
|
|
|
|
|
def test_untracked_box_never_touches_movestate(qtbot):
|
|
# MotorMoveGroup owns movestate on the boxes it registers; the default
|
|
# NumberLineEdit must therefore stay away from the property entirely.
|
|
box = NumberLineEdit(-720, 720, default=100.0, decimals=2)
|
|
qtbot.addWidget(box)
|
|
_type(qtbot, box, "300")
|
|
assert box.property("movestate") is None
|
|
|
|
|
|
def test_programmatic_update_clears_pending(tracked_box, qtbot):
|
|
_type(qtbot, tracked_box, "300")
|
|
tracked_box.force_update_value(120.0)
|
|
assert tracked_box.property("movestate") == ""
|
|
_type(qtbot, tracked_box, "300")
|
|
tracked_box.update_value(500.0)
|
|
assert tracked_box.property("movestate") == ""
|
|
|
|
|
|
def test_db_override_typing_pending_until_commit(qapp, qtbot):
|
|
w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
|
|
qtbot.addWidget(w)
|
|
_type(qtbot, w.editor, "300")
|
|
assert w.editor.property("movestate") == "pending"
|
|
qtbot.keyClick(w.editor, Qt.Key.Key_Return)
|
|
assert w.editor.property("movestate") == ""
|
|
assert w.value == 300.0
|
|
assert w.source() == DbOverrideLineEdit.SOURCE_MINE
|
|
|
|
|
|
def test_db_override_source_toggle_clears_pending(qapp, qtbot):
|
|
w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
|
|
qtbot.addWidget(w)
|
|
_type(qtbot, w.editor, "300") # typing, no commit
|
|
assert w.editor.property("movestate") == "pending"
|
|
# a panel refresh (radio toggle, DB push) discards the uncommitted text
|
|
w.set_source(DbOverrideLineEdit.SOURCE_DB, emit=False)
|
|
assert w.editor.property("movestate") == ""
|
|
assert w.value == 200.0
|
|
|
|
|
|
def test_energy_spin_motor_move_semantics(settings_panel, daq_status_factory):
|
|
# same placeholder limits as the Beamline setup row (shared constants)
|
|
assert settings_panel.energy_spin.minimum() == 4.0
|
|
assert settings_panel.energy_spin.maximum() == 20.0
|
|
assert "Beamline Scientist" in settings_panel.energy_spin.toolTip()
|
|
|
|
# Same MotorMoveGroup wiring as the Beamline setup row (full state walk
|
|
# tested there); here: stage -> pending, button sends -> moving, readback
|
|
# arrival -> neutral.
|
|
box = settings_panel.energy_spin # movestate sits on the spin itself
|
|
assert not settings_panel.change_energy_button.isEnabled()
|
|
|
|
settings_panel.update_daq_status(daq_status_factory()) # readback 12.0 keV
|
|
assert settings_panel.energy_spin.value() == 12.0
|
|
|
|
settings_panel.energy_spin.setValue(12.4)
|
|
assert box.property("movestate") == "pending"
|
|
assert settings_panel.change_energy_button.isEnabled()
|
|
|
|
sent = []
|
|
settings_panel.change_energy.connect(sent.append)
|
|
settings_panel.change_energy_button.click()
|
|
assert sent and sent[-1] == pytest.approx(12400.0)
|
|
assert box.property("movestate") == "moving"
|
|
|
|
settings_panel._energy_state.update_actual(12.3995)
|
|
assert box.property("movestate") == ""
|