Files
AareDAQ/tests/unit/gui/test_data_collection_settings.py
duan_jandClaude Fable 5.1 2d0235071c
CI / lint (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
CI / lint (pull_request) Successful in 54s
CI / test (3.12) (pull_request) Successful in 59s
CI / test (3.13) (pull_request) Successful in 58s
CI / test (3.14) (pull_request) Successful in 1m0s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Successful in 1m7s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Successful in 1m9s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Successful in 1m12s
CI / test-with-coverage (pull_request) Successful in 1m32s
CI / coverage-analysis (pull_request) Successful in 21s
fix: take the aareDB transmission as the fraction aarecommon >= 0.7 declares
CI resolves aarecommon 0.7.3, where DataCollectionParameters.transmission
is a 0-to-1 fraction that rejects anything above 1.0 (the percent sheets
are converted inside AareDB >= 0.83, which main already requires). The
divide-by-100 from the earlier 'percentage' fix therefore failed the
model test on CI and would have turned 20% into 0.2% at the beamline.
Pass the fraction through and pin aarecommon>=0.7.3 so the older int
percentage model can no longer be installed; relock.

Also satisfy the diff typecheck gate: basedpyright only counts instance
variables assigned in __init__, so the Database/User toggle widgets are
created there and _build_source_toggle only lays them out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:53:26 +02:00

451 lines
16 KiB
Python

"""Tests for the scan settings panels.
Covers where the numbers in the fields come from (the mounted sample's
spreadsheet row, the user's own values, the panel defaults), that a user value
survives a new sample all the way into the scan request while the settings the
user did not touch keep following the sample, and that a number typed but never
committed does not take effect.
"""
import types
from typing import Any, cast
import pytest
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
from aarecommon.math.diffraction_geometry import DiffractionGeometry
from aarecommon.models.models import (
BeamlineStateEnum,
DataCollectionParameters,
SampleGeometryModel,
SampleShortInfo,
)
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.panels.scan_settings_panel import SampleParameters
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
from aare.gui.widgets.number_line_edit import NumberLineEdit
def _commit(field: NumberLineEdit, text: str):
"""Type into a field and commit it, as Enter or leaving the field does."""
field.setText(text)
field.on_editing_finished()
def _mount(panel, **params):
"""Mount a sample whose spreadsheet row holds `params`; same two steps
update_daq_status takes when the parameters of the mounted sample change."""
panel._db_params = SampleParameters(**params)
panel._refresh_fields()
# ---------------------------------------------------------------------------
# The spreadsheet row -> SampleParameters
# ---------------------------------------------------------------------------
def test_sample_parameters_translate_the_spreadsheet_row():
# The real model, so the column names are checked against aareDB and not
# against a stand-in that agrees with whatever this file happens to say.
sample = SampleShortInfo(
db_id=1,
puck_name="puck1",
dewar_name="dewar1",
sample_name="sample1",
run_number=1,
pin=1,
aaredb_params=DataCollectionParameters(
targetresolution=1.5,
transmission=0.2, # 0-to-1 fraction; aareDB converts the percent sheets
totalangle=180,
oscillation=0.1,
exposure=0.02,
),
)
assert SampleParameters.from_sample(sample) == SampleParameters(
resolution_a=1.5,
transmission=0.2,
total_angle_deg=180.0,
image_angle_deg=0.1,
exp_time_s=0.02,
)
def test_sample_parameters_are_empty_without_a_sample():
assert SampleParameters.from_sample(None) == SampleParameters()
def test_a_column_aaredb_does_not_have_is_ignored():
# aareDB owns these names: a renamed column must leave the panel on its
# defaults, not break the status loop it is read from.
params = types.SimpleNamespace(targetresolution=1.5)
sample = cast(Any, types.SimpleNamespace(aaredb_params=params))
assert SampleParameters.from_sample(sample) == SampleParameters(resolution_a=1.5)
# ---------------------------------------------------------------------------
# Rotation panel
# ---------------------------------------------------------------------------
@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)
@pytest.fixture
def runnable_panel(panel, monkeypatch):
"""A rotation panel whose Run button reaches the request: beamline ready
and the file path/precondition dialogs answered."""
panel._beamline_state = BeamlineStateEnum.SampleAlignment
monkeypatch.setattr(panel, "check_before_run", lambda scan_kind: True)
return panel
def test_fields_follow_the_mounted_sample(panel):
_mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02)
assert panel.high_res_enter.committed_value == 2.5
assert panel.dtz_enter.committed_value == pytest.approx(panel._res_to_dtz(2.5), abs=0.01)
assert panel.transmission_enter.committed_value == 0.5
assert panel.total_angle.committed_value == 180.0
assert panel.image_time_enter.committed_value == 0.02
# An empty spreadsheet cell leaves the panel default in place.
assert panel.image_angle.committed_value == panel._default_image_angle
def test_editing_resolution_updates_dtz_and_selects_user_values(panel):
_commit(panel.high_res_enter, "2.00")
assert panel._show_user_values
assert panel._user_radio.isChecked()
assert panel.dtz_enter.committed_value == pytest.approx(panel._res_to_dtz(2.0), abs=0.01)
def test_editing_dtz_updates_resolution(panel, diffraction):
_commit(panel.dtz_enter, "250.00")
assert panel.high_res_enter.committed_value == pytest.approx(
diffraction.resolution_angstrom(250.0), abs=0.01
)
def test_user_value_survives_the_next_sample(runnable_panel):
panel = runnable_panel
_mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02)
# The user overrides the resolution the spreadsheet asks for.
_commit(panel.high_res_enter, "3.00")
user_dtz = panel.dtz_enter.committed_value
# Next sample, same spreadsheet values: the user's resolution stays put...
_mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02)
assert panel.high_res_enter.committed_value == 3.00
assert panel.dtz_enter.committed_value == user_dtz
# ...and it is what the scan actually collects at.
requests = []
panel.rotation_scan.connect(requests.append)
panel.run_measurement()
assert requests[-1].dtz == user_dtz
def test_untouched_settings_keep_following_the_sample(panel):
_mount(panel, total_angle_deg=180.0, exp_time_s=0.02)
_commit(panel.image_time_enter, "0.0500") # only the image time is the user's
_mount(panel, total_angle_deg=360.0, exp_time_s=0.02)
assert panel.image_time_enter.committed_value == 0.05
assert panel.total_angle.committed_value == 360.0
def test_toggle_swaps_between_the_two_sets(panel):
_mount(panel, resolution_a=2.5, exp_time_s=0.02)
_commit(panel.image_time_enter, "0.0500")
panel._database_radio.setChecked(True) # back to Database values
assert panel.image_time_enter.committed_value == 0.02
assert panel.high_res_enter.committed_value == 2.5
panel._user_radio.setChecked(True) # the user's set is remembered
assert panel.image_time_enter.committed_value == 0.05
def test_a_new_sample_is_shown_while_on_database_values(panel):
_mount(panel, exp_time_s=0.02)
assert panel.image_time_enter.committed_value == 0.02
_mount(panel, exp_time_s=0.03)
assert panel.image_time_enter.committed_value == 0.03
def test_text_that_was_never_entered_does_not_take_effect(runnable_panel):
panel = runnable_panel
_mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02)
database_dtz = panel.dtz_enter.committed_value
requests = []
panel.rotation_scan.connect(requests.append)
panel.high_res_enter.setText("3.00") # typed, never entered
panel.run_measurement()
assert not panel._show_user_values
assert requests[-1].dtz == database_dtz
# Committing it is what makes it the user's.
panel.high_res_enter.on_editing_finished()
panel.run_measurement()
assert panel._show_user_values
assert requests[-1].dtz == pytest.approx(panel._res_to_dtz(3.00), abs=0.01)
def test_screening_transmission_is_the_panels_own(runnable_panel):
panel = runnable_panel
_mount(panel, transmission=0.5)
_commit(panel.screening_transmission_enter, "0.1000")
requests = []
panel.rotation_scan.connect(requests.append)
panel.run_screening()
assert requests[-1].transmission == 0.1 # not the 0.5 the rotation uses
def test_downstream_gets_the_active_values(panel):
dtz_seen = []
transmission_seen = []
panel.dtz_updated.connect(dtz_seen.append)
panel.transmission_updated.connect(transmission_seen.append)
_commit(panel.dtz_enter, "250.00")
assert dtz_seen[-1] == 250.0
# The transmission was never touched, so it keeps following the sample.
_mount(panel, transmission=0.4)
assert transmission_seen[-1] == 0.4
panel._database_radio.setChecked(True)
assert dtz_seen[-1] == pytest.approx(panel.dtz_enter.committed_value)
# ---------------------------------------------------------------------------
# Raster panel
# ---------------------------------------------------------------------------
@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_is_reported_and_echoed_back(raster_panel):
emitted = []
raster_panel.grid_size_updated.connect(lambda x, y: emitted.append((x, y)))
_commit(raster_panel.width_enter, "20")
assert emitted[-1][0] == pytest.approx(0.020)
# The grid manager answers with the geometry it settled on; the field
# follows it and does not emit again.
before = len(emitted)
raster_panel.grid_scan_size_change(4, 5, 0.035, 0.020)
assert raster_panel.width_enter.committed_value == 35.0
assert len(emitted) == before
def test_raster_exposure_follows_the_toggle(raster_panel):
_mount(raster_panel, exp_time_s=0.04)
assert raster_panel.image_time_enter.committed_value == 0.04
_commit(raster_panel.image_time_enter, "0.0800")
_mount(raster_panel, exp_time_s=0.04)
assert raster_panel.image_time_enter.committed_value == 0.08
raster_panel._database_radio.setChecked(True)
assert raster_panel.image_time_enter.committed_value == 0.04
def test_grid_element_size_stays_out_of_the_toggle(raster_panel):
_commit(raster_panel.width_enter, "20")
# A panel-only field must not take the panel to user values.
assert not raster_panel._show_user_values
# ---------------------------------------------------------------------------
# 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
assert tracked_box.committed_value == 100.0
qtbot.keyClick(tracked_box, Qt.Key.Key_Return)
assert seen == [300.0]
assert tracked_box.committed_value == 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_set_committed_value_keeps_a_value_that_did_not_change(tracked_box, qtbot):
_type(qtbot, tracked_box, "300") # typing, no commit
tracked_box.set_committed_value(100.0) # what the field already holds
assert tracked_box.text() == "300" # the typing survives
tracked_box.set_committed_value(120.0) # a real change wins
assert tracked_box.committed_value == 120.0
assert tracked_box.property("movestate") == ""
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") == ""