Files
eco/tests/test_scan_launcher_qt.py
T
2026-08-21 15:10:25 +02:00

641 lines
21 KiB
Python

"""Tests for eco.widgets.scan_launcher_qt.
Uses lightweight fake doubles (FakeAdjustable/FakeDetector/FakeRoot for the
component picker's namespace tree, FakeQueue/FakeQueueItem/FakeScan for the
queue panel, FakeScans for the launcher's Scans-shaped methods) rather than
real StepScan/ScanQueue machinery, so these tests exercise the widget logic
in isolation without needing real devices or a real background scan thread.
"""
import weakref
from pathlib import Path
import pytest
pytest.importorskip("qtpy")
from qtpy import QtWidgets
import eco.widgets.scan_launcher_qt as slq
# ---------------------------------------------------------------------------
# fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def qapp():
return QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
@pytest.fixture(autouse=True)
def _redirect_home(tmp_path, monkeypatch):
# ComponentBookmarks/RecentComponents default to Path.home()/".eco"/...
# when not given an explicit path -- redirect so tests never touch the
# real account's home directory.
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# ---------------------------------------------------------------------------
# fake doubles
# ---------------------------------------------------------------------------
class _FakeStatusCollection:
def __init__(self, items):
self._list = [weakref.ref(item) for item in items]
class FakeAdjustable:
def __init__(self, name, value=0.0):
self.name = name
self._value = value
def get_current_value(self):
return self._value
def set_target_value(self, value):
self._value = value
class _Handle:
def wait(self, timeout=None):
return None
return _Handle()
class FakeDetector:
def __init__(self, name, value=1.0):
self.name = name
self._value = value
def get_current_value(self):
return self._value
class FakeRoot:
def __init__(self, children, name="fakeroot"):
self.name = name
self._children = list(children) # keep strong refs alive for the weakrefs below
self.status_collection = _FakeStatusCollection(self._children)
class FakeQueueItem:
def __init__(self, state="pending", scan=None, description=""):
self.state = state
self.scan = scan
self.description = description
def __repr__(self):
return f"<FakeQueueItem {self.state!r} {self.description!r}>"
class FakeScan:
def __init__(self, total, done, readbacks, adjustables, paused=False):
self.scan_info = {"scan_values_all": [None] * total}
self._values_done = [None] * done
self.readbacks = readbacks
self.adjustables = adjustables
self._paused = paused
def is_paused(self):
return self._paused
class FakeQueue:
def __init__(self, name="default"):
self.name = name
self.pause_calls = 0
self.resume_calls = 0
self.pause_current_calls = 0
self.resume_current_calls = 0
self.stop_current_calls = 0
self.saved = []
self.resume_from_file_calls = []
self._status = {"name": name, "paused": False, "current": None, "pending": []}
def status(self):
return dict(self._status, pending=list(self._status["pending"]))
def pause(self):
self.pause_calls += 1
self._status["paused"] = True
def resume(self):
self.resume_calls += 1
self._status["paused"] = False
def pause_current(self):
self.pause_current_calls += 1
def resume_current(self):
self.resume_current_calls += 1
def stop_current(self):
self.stop_current_calls += 1
def save_paused(self, item, path):
self.saved.append((item, path))
def resume_from_file(self, path, root, counters):
self.resume_from_file_calls.append((path, root, counters))
return FakeQueueItem(state="pending", description="resumed")
class FakeScansQueues:
def __init__(self, queue):
self._queue = queue
def __getitem__(self, name):
return self._queue
class FakeScans:
def __init__(self, queue):
self.name = "scans"
self.queues = FakeScansQueues(queue)
self.calls = []
def _record(self, name, args, kwargs):
self.calls.append((name, args, kwargs))
return FakeQueueItem(state="pending", description=kwargs.get("description", ""))
def ascan(self, adjustable, start, end, n, n_pulses, **kwargs):
return self._record("ascan", (adjustable, start, end, n, n_pulses), kwargs)
def dscan(self, adjustable, start, end, n, n_pulses, **kwargs):
return self._record("dscan", (adjustable, start, end, n, n_pulses), kwargs)
def acquire(self, n_pulses, **kwargs):
return self._record("acquire", (n_pulses,), kwargs)
def meshscan(self, *adj_specs, **kwargs):
return self._record("meshscan", adj_specs, kwargs)
# ---------------------------------------------------------------------------
# ComponentPickerQt / MultiComponentPickerQt
# ---------------------------------------------------------------------------
def test_component_picker_qt_starts_empty(qapp):
root = FakeRoot([FakeAdjustable("motor1")])
picker = slq.ComponentPickerQt(root, kind_filter="Adjustable")
assert picker.value is None
assert picker.path is None
assert picker.label.text() == "(none selected)"
def test_component_picker_qt_clear_resets_and_emits_changed(qapp):
motor = FakeAdjustable("motor1")
root = FakeRoot([motor])
picker = slq.ComponentPickerQt(root, kind_filter="Adjustable")
picker.value = motor
picker.path = "motor1"
seen = []
picker.changed.connect(lambda: seen.append(True))
picker.clear()
assert picker.value is None
assert picker.path is None
assert picker.label.text() == picker.placeholder
assert seen == [True]
def test_component_picker_qt_pick_dialog_sets_value_on_accept(qapp, monkeypatch):
motor = FakeAdjustable("motor1", value=3.0)
root = FakeRoot([motor])
picker = slq.ComponentPickerQt(root, kind_filter="Adjustable")
changed = []
picker.changed.connect(lambda: changed.append(True))
captured = {}
orig_selector_init = slq.ComponentSelectorQt.__init__
def wrapped_init(self, *a, **kw):
orig_selector_init(self, *a, **kw)
captured["selector"] = self
monkeypatch.setattr(slq.ComponentSelectorQt, "__init__", wrapped_init)
def fake_exec(self):
captured["selector"]._select("motor1", motor, "adjustable")
return QtWidgets.QDialog.Accepted
monkeypatch.setattr(QtWidgets.QDialog, "exec_", fake_exec)
picker._pick()
assert picker.value is motor
assert picker.path == "motor1"
assert picker.label.text() == "motor1"
assert changed == [True]
def test_component_picker_qt_pick_dialog_cancelled_leaves_value_unset(qapp, monkeypatch):
root = FakeRoot([FakeAdjustable("motor1")])
picker = slq.ComponentPickerQt(root, kind_filter="Adjustable")
monkeypatch.setattr(QtWidgets.QDialog, "exec_", lambda self: QtWidgets.QDialog.Rejected)
picker._pick()
assert picker.value is None
def test_multi_component_picker_qt_get_value(qapp):
d1 = FakeDetector("d1")
d2 = FakeDetector("d2")
root = FakeRoot([d1, d2])
multi = slq.MultiComponentPickerQt(root, kind_filter="Detector")
assert multi.get_value() == []
p1 = multi.add_row()
p1.value = d1
p2 = multi.add_row()
p2.value = d2
assert multi.get_value() == [d1, d2]
# ---------------------------------------------------------------------------
# StepSpecQt / ScanAxisRowQt / ScanBuilderQt
# ---------------------------------------------------------------------------
def test_step_spec_qt_linear_default(qapp):
spec = slq.StepSpecQt(start=1.0, end=5.0, n=4)
assert spec.get_spec() == (1.0, 5.0, 4)
def test_step_spec_qt_position_list_mode(qapp):
spec = slq.StepSpecQt()
spec.mode_dd.setCurrentText("position list")
spec.list_w.setText("1,2,3")
assert spec.get_spec() == ([1.0, 2.0, 3.0],)
def test_scan_axis_row_qt_single_axis(qapp):
motor = FakeAdjustable("motor1")
root = FakeRoot([motor])
row = slq.ScanAxisRowQt(root, allow_simultaneous=True)
picker, spec, _ = row._sub_rows[0]
picker.value = motor
spec.start_w.setValue(0.0)
spec.end_w.setValue(2.0)
spec.n_w.setValue(5)
assert row.get_adj_spec() == (motor, 0.0, 2.0, 5)
def test_scan_axis_row_qt_simultaneous_mode_grows_and_collects_all(qapp):
m1 = FakeAdjustable("m1")
m2 = FakeAdjustable("m2")
root = FakeRoot([m1, m2])
row = slq.ScanAxisRowQt(root, allow_simultaneous=True)
row.mode_dd.setCurrentText("simultaneous (co-moving)")
row._sub_rows[0][0].value = m1
row._add_sub_row()
row._sub_rows[1][0].value = m2
spec = row.get_adj_spec()
assert spec == [(m1, 0.0, 1.0, 10), (m2, 0.0, 1.0, 10)]
# switching back to "single axis" collapses to one sub-row
row.mode_dd.setCurrentText("single axis")
assert len(row._sub_rows) == 1
def test_scan_axis_row_qt_meshscan_never_shows_simultaneous_toggle(qapp):
root = FakeRoot([])
row = slq.ScanAxisRowQt(root, allow_simultaneous=False)
assert row.mode_dd is None
assert len(row._sub_rows) == 1
def test_scan_builder_qt_get_adj_specs_and_add_axis(qapp):
m1 = FakeAdjustable("m1")
root = FakeRoot([m1])
builder = slq.ScanBuilderQt(root, allow_simultaneous=False)
picker, spec, _ = builder._axis_rows[0][0]._sub_rows[0]
picker.value = m1
assert builder.get_adj_specs() == [(m1, 0.0, 1.0, 10)]
builder.add_axis()
assert len(builder._axis_rows) == 2
# ---------------------------------------------------------------------------
# structured form builders
# ---------------------------------------------------------------------------
def test_build_ascan_like_form_qt_get_call(qapp):
motor = FakeAdjustable("motor1")
root = FakeRoot([motor])
widget, get_call = slq._build_ascan_like_form_qt(None, root, None, None)
pickers = widget.findChildren(slq.ComponentPickerQt)
assert len(pickers) == 1
pickers[0].value = motor
start_w, end_w, _settling_w = widget.findChildren(QtWidgets.QDoubleSpinBox)
start_w.setValue(0.5)
end_w.setValue(2.5)
n_w, n_pulses_w, rep_w = widget.findChildren(QtWidgets.QSpinBox)
n_w.setValue(7)
n_pulses_w.setValue(50)
args, kwargs = get_call()
assert args == [motor, 0.5, 2.5, 7, 50]
assert kwargs["settling_time"] == 0.0
assert kwargs["repetitions"] == 1
assert kwargs["return_at_end"] == "timeout"
assert "counters" not in kwargs
def test_build_acquire_form_qt_get_call(qapp):
root = FakeRoot([])
widget, get_call = slq._build_acquire_form_qt(None, root, None, None)
n_pulses_w, n_rep_w = widget.findChildren(QtWidgets.QSpinBox)
n_pulses_w.setValue(200)
n_rep_w.setValue(3)
args, kwargs = get_call()
assert args == [200]
assert kwargs["N_repetitions"] == 3
assert kwargs["return_at_end"] is True
assert "repetitions" not in kwargs
def test_build_ascan_position_list_form_qt_get_call(qapp):
motor = FakeAdjustable("motor1")
root = FakeRoot([motor])
widget, get_call = slq._build_ascan_position_list_form_qt(None, root, None, None)
widget.findChildren(slq.ComponentPickerQt)[0].value = motor
positions_w = widget.findChildren(QtWidgets.QLineEdit)[0]
positions_w.setText("0,0.5,1")
args, kwargs = get_call()
assert args == [motor, [0.0, 0.5, 1.0], 100]
def test_build_snakescan_form_qt_get_call(qapp):
slow = FakeAdjustable("slow")
fast = FakeAdjustable("fast")
root = FakeRoot([slow, fast])
widget, get_call = slq._build_snakescan_form_qt(None, root, None, None)
picker_slow, picker_fast = widget.findChildren(slq.ComponentPickerQt)
picker_slow.value = slow
picker_fast.value = fast
args, kwargs = get_call()
assert args == [slow, 1.0, 5, fast, 1.0]
def test_build_a2scan_form_qt_get_call(qapp):
a0 = FakeAdjustable("a0")
a1 = FakeAdjustable("a1")
root = FakeRoot([a0, a1])
widget, get_call = slq._build_a2scan_form_qt(None, root, None, None)
picker0, picker1 = widget.findChildren(slq.ComponentPickerQt)
picker0.value = a0
picker1.value = a1
args, kwargs = get_call()
assert args == [a0, 0.0, 1.0, a1, 0.0, 1.0, 10, 100]
def test_build_grid_form_qt_meshscan_get_call(qapp):
m1 = FakeAdjustable("m1")
root = FakeRoot([m1])
build = slq._build_grid_form_qt(allow_simultaneous=False)
widget, get_call = build(None, root, None, None)
widget.findChildren(slq.ComponentPickerQt)[0].value = m1
args, kwargs = get_call()
assert args == [(m1, 0.0, 1.0, 10)]
assert kwargs["N_pulses"] == 100
assert kwargs["scanning_order"] == "last_fastest"
def test_common_extra_kwargs_qt_includes_counters_only_if_picked(qapp):
d1 = FakeDetector("d1")
root = FakeRoot([d1])
box, read = slq._build_common_extra_kwargs_qt(root, None, None)
kw = read()
assert "counters" not in kw
counters_w = box.findChildren(slq.MultiComponentPickerQt)[0]
counters_w.add_row().value = d1
kw = read()
assert kw["counters"] == [d1]
# ---------------------------------------------------------------------------
# generic reflection-based fallback
# ---------------------------------------------------------------------------
def test_build_generic_form_qt_reflects_signature_defaults(qapp):
def sample(a, b=2, *args, c=3, **kwargs):
pass
widget, get_call = slq._build_generic_form_qt(sample)
args, kwargs = get_call()
assert args == ["", 2]
assert kwargs == {"c": 3}
def test_build_generic_form_qt_var_keyword_parses_json(qapp):
def sample(**kwargs):
pass
widget, get_call = slq._build_generic_form_qt(sample)
edit = widget.findChildren(QtWidgets.QLineEdit)[0]
edit.setText('{"foo": 1, "bar": "baz"}')
args, kwargs = get_call()
assert kwargs == {"foo": 1, "bar": "baz"}
# ---------------------------------------------------------------------------
# ScanQueuePanel
# ---------------------------------------------------------------------------
def test_scan_queue_panel_poll_updates_status_and_progress(qapp):
queue = FakeQueue()
root = FakeRoot([])
panel = slq.ScanQueuePanel(queue, root)
try:
motor = FakeAdjustable("motor1")
scan = FakeScan(total=5, done=2, readbacks=[[0.0], [0.5]], adjustables=[motor])
item = FakeQueueItem(state="running", scan=scan)
panel.track_item(item)
queue._status["current"] = repr(item)
panel._poll()
assert panel.progress.maximum() == 5
assert panel.progress.value() == 2
assert "step 2/5" in panel.progress.format()
assert "Current:" in panel.current_label.text()
finally:
panel.stop()
def test_scan_queue_panel_poll_idle_when_nothing_tracked(qapp):
queue = FakeQueue()
root = FakeRoot([])
panel = slq.ScanQueuePanel(queue, root)
try:
panel._poll()
assert panel.progress.format() == "no active scan"
finally:
panel.stop()
def test_scan_queue_panel_pause_resume_queue_buttons(qapp):
queue = FakeQueue()
root = FakeRoot([])
panel = slq.ScanQueuePanel(queue, root)
try:
panel.pause_queue_btn.setChecked(True)
assert queue.pause_calls == 1
assert panel.pause_queue_btn.text() == "Resume queue"
panel.pause_queue_btn.setChecked(False)
assert queue.resume_calls == 1
assert panel.pause_queue_btn.text() == "Pause queue"
finally:
panel.stop()
def test_scan_queue_panel_save_paused_needs_a_paused_tracked_item(qapp, monkeypatch):
queue = FakeQueue()
root = FakeRoot([])
panel = slq.ScanQueuePanel(queue, root)
try:
shown = []
monkeypatch.setattr(
QtWidgets.QMessageBox, "information", lambda *a, **kw: shown.append(a)
)
panel._on_save_paused()
assert shown
assert queue.saved == []
finally:
panel.stop()
def test_scan_queue_panel_save_paused_saves_when_item_is_paused(qapp, monkeypatch, tmp_path):
queue = FakeQueue()
root = FakeRoot([])
panel = slq.ScanQueuePanel(queue, root)
try:
scan = FakeScan(total=3, done=1, readbacks=[[0.0]], adjustables=[], paused=True)
item = FakeQueueItem(state="running", scan=scan)
panel.track_item(item)
path = str(tmp_path / "paused.json")
monkeypatch.setattr(QtWidgets.QFileDialog, "getSaveFileName", lambda *a, **kw: (path, ""))
monkeypatch.setattr(QtWidgets.QMessageBox, "information", lambda *a, **kw: None)
panel._on_save_paused()
assert queue.saved == [(item, path)]
finally:
panel.stop()
def test_scan_queue_panel_resume_from_file_picks_counters_then_submits(qapp, monkeypatch, tmp_path):
d1 = FakeDetector("d1")
queue = FakeQueue()
root = FakeRoot([d1])
panel = slq.ScanQueuePanel(queue, root)
try:
path = str(tmp_path / "resume.json")
monkeypatch.setattr(QtWidgets.QFileDialog, "getOpenFileName", lambda *a, **kw: (path, ""))
def fake_exec(dialog_self):
picker = dialog_self.findChildren(slq.MultiComponentPickerQt)[0]
picker.add_row().value = d1
return QtWidgets.QDialog.Accepted
monkeypatch.setattr(QtWidgets.QDialog, "exec_", fake_exec)
panel._on_resume_from_file()
assert queue.resume_from_file_calls == [(path, root, [d1])]
assert len(panel._tracked) == 1
finally:
panel.stop()
def test_scan_queue_panel_resume_from_file_rejects_empty_counters(qapp, monkeypatch, tmp_path):
queue = FakeQueue()
root = FakeRoot([])
panel = slq.ScanQueuePanel(queue, root)
try:
path = str(tmp_path / "resume.json")
monkeypatch.setattr(QtWidgets.QFileDialog, "getOpenFileName", lambda *a, **kw: (path, ""))
monkeypatch.setattr(QtWidgets.QDialog, "exec_", lambda self: QtWidgets.QDialog.Accepted)
warned = []
monkeypatch.setattr(QtWidgets.QMessageBox, "warning", lambda *a, **kw: warned.append(a))
panel._on_resume_from_file()
assert queue.resume_from_file_calls == []
assert warned
finally:
panel.stop()
# ---------------------------------------------------------------------------
# ScanLauncherQt
# ---------------------------------------------------------------------------
def test_scan_launcher_qt_run_submits_through_queue(qapp):
motor = FakeAdjustable("motor1")
root = FakeRoot([motor])
queue = FakeQueue()
scans = FakeScans(queue)
launcher = slq.ScanLauncherQt(scans, root=root, methods=["ascan"], queue_name="default")
try:
picker = launcher._pages["ascan"][0].findChildren(slq.ComponentPickerQt)[0]
picker.value = motor
launcher._on_run()
assert len(scans.calls) == 1
name, args, kwargs = scans.calls[0]
assert name == "ascan"
assert args[0] is motor
assert kwargs["scan_queue"] == "default"
assert len(launcher.queue_panel._tracked) == 1
assert "Submitted" in launcher.status_label.text()
finally:
launcher.stop()
def test_scan_launcher_qt_method_switch_updates_stack(qapp):
root = FakeRoot([])
queue = FakeQueue()
scans = FakeScans(queue)
launcher = slq.ScanLauncherQt(
scans, root=root, methods=["ascan", "acquire"], queue_name="default"
)
try:
launcher.method_combo.setCurrentText("acquire")
assert launcher._stack.currentWidget() is launcher._pages["acquire"][0]
launcher._on_run()
assert scans.calls[-1][0] == "acquire"
finally:
launcher.stop()
def test_scan_launcher_qt_run_reports_errors_without_crashing(qapp):
root = FakeRoot([])
queue = FakeQueue()
scans = FakeScans(queue)
def boom(*a, **kw):
raise RuntimeError("no adjustable picked")
scans.acquire = boom
launcher = slq.ScanLauncherQt(scans, root=root, methods=["acquire"], queue_name="default")
try:
launcher._on_run()
assert "Error" in launcher.status_label.text()
assert launcher.queue_panel._tracked == []
finally:
launcher.stop()
def test_scan_launcher_qt_falls_back_to_generic_form_for_unknown_method(qapp):
root = FakeRoot([])
queue = FakeQueue()
scans = FakeScans(queue)
launcher = slq.ScanLauncherQt(scans, root=root, methods=["meshscan"], queue_name="default")
try:
# meshscan IS structured, so use it to confirm the structured page
# was picked over the generic fallback path
assert "ScanBuilderQt" in [
type(w).__name__ for w in launcher._pages["meshscan"][0].findChildren(QtWidgets.QWidget)
]
finally:
launcher.stop()