mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-09-07 17:02:38 +02:00
2221 lines
82 KiB
Python
2221 lines
82 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
from unittest import mock
|
|
from unittest.mock import MagicMock
|
|
|
|
import numpy as np
|
|
import pyqtgraph as pg
|
|
import pytest
|
|
from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
|
|
from qtpy.QtCore import QTimer
|
|
from qtpy.QtWidgets import QApplication, QCheckBox, QDialog, QDialogButtonBox, QDoubleSpinBox
|
|
|
|
from bec_widgets.widgets.plots.plot_base import UIMode
|
|
from bec_widgets.widgets.plots.waveform.curve import DeviceSignal
|
|
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
|
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
|
|
ScanHistoryBrowser,
|
|
)
|
|
from tests.unit_tests.client_mocks import (
|
|
create_dummy_scan_item,
|
|
dap_plugin_message,
|
|
inject_scan_history,
|
|
mocked_client,
|
|
mocked_client_with_dap,
|
|
)
|
|
|
|
from .conftest import create_widget
|
|
|
|
# pylint: disable=unexpected-keyword-arg
|
|
|
|
##################################################
|
|
# Waveform widget base functionality tests
|
|
##################################################
|
|
|
|
|
|
def make_alignment_fit_summary(center: float | None = None) -> dict:
|
|
params = []
|
|
if center is not None:
|
|
params.append(["center", center, True, None, -np.inf, np.inf, None, 0.1, {}, 0.0, None])
|
|
params.append(["sigma", 0.5, True, None, 0.0, np.inf, None, 0.1, {}, 1.0, None])
|
|
return {
|
|
"model": "Model(test)",
|
|
"method": "leastsq",
|
|
"chisqr": 1.0,
|
|
"redchi": 1.0,
|
|
"rsquared": 0.99,
|
|
"message": "Fit succeeded.",
|
|
"params": params,
|
|
}
|
|
|
|
|
|
##################################################
|
|
# DataAPI test helpers
|
|
##################################################
|
|
|
|
|
|
def _fake_bridge_factory(monkeypatch, gated_bytes: int | None = None):
|
|
"""
|
|
Patch QtDataSubscription in the waveform module with a lightweight
|
|
stand-in. Returns the list of created bridges (newest last).
|
|
|
|
Args:
|
|
gated_bytes(int | None): When given, every bridge created with a
|
|
``size_limit_bytes`` smaller than this value reports itself as
|
|
size-gated with ``estimated_bytes = gated_bytes`` (mirrors the
|
|
backend gate without any file I/O).
|
|
"""
|
|
created = []
|
|
|
|
class _FakeBridge:
|
|
def __init__(
|
|
self,
|
|
client,
|
|
sources,
|
|
scan="live",
|
|
parent=None,
|
|
min_emit_interval=0.1,
|
|
size_limit_bytes=None,
|
|
):
|
|
self.client = client
|
|
self.sources = list(sources)
|
|
self.scan = scan
|
|
self.scan_id = None if scan == "live" else scan
|
|
self.healthy = True
|
|
self.closed = False
|
|
self.updated = MagicMock()
|
|
self.size_limit_bytes = size_limit_bytes
|
|
self.estimated_bytes = gated_bytes
|
|
self.size_gated = (
|
|
gated_bytes is not None
|
|
and size_limit_bytes is not None
|
|
and gated_bytes > size_limit_bytes
|
|
)
|
|
self.confirmed = False
|
|
|
|
def confirm_size(self):
|
|
self.confirmed = True
|
|
self.size_gated = False
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
def factory(
|
|
client, sources, scan="live", parent=None, min_emit_interval=0.1, size_limit_bytes=None
|
|
):
|
|
bridge = _FakeBridge(client, sources, scan=scan, size_limit_bytes=size_limit_bytes)
|
|
created.append(bridge)
|
|
return bridge
|
|
|
|
monkeypatch.setattr("bec_widgets.widgets.plots.waveform.waveform.QtDataSubscription", factory)
|
|
return created
|
|
|
|
|
|
def _monitored_source(device, values, entry=None, timestamps=None, ordinals=None, as_numpy=False):
|
|
from bec_lib.data_api.models import SourceData
|
|
|
|
entry = entry or device
|
|
ordinals = tuple(range(len(values))) if ordinals is None else tuple(ordinals)
|
|
if timestamps is None:
|
|
timestamps = tuple(float(i) for i in ordinals)
|
|
wrap = (lambda seq: np.asarray(seq)) if as_numpy else tuple
|
|
return SourceData(
|
|
device=device,
|
|
entry=entry,
|
|
kind="monitored",
|
|
ordinals=wrap(ordinals),
|
|
values=wrap(values),
|
|
timestamps=wrap(timestamps),
|
|
complete=True,
|
|
)
|
|
|
|
|
|
def _async_source(
|
|
device,
|
|
values,
|
|
entry=None,
|
|
timestamps=None,
|
|
update_type="add",
|
|
max_shape=(None,),
|
|
kind="async",
|
|
ordinals=None,
|
|
as_numpy=False,
|
|
):
|
|
from bec_lib.data_api.models import SourceData
|
|
|
|
entry = entry or device
|
|
ordinals = tuple(range(len(values))) if ordinals is None else tuple(ordinals)
|
|
if timestamps is None:
|
|
timestamps = tuple(float(i) for i in ordinals)
|
|
wrap = (lambda seq: np.asarray(seq)) if as_numpy else tuple
|
|
return SourceData(
|
|
device=device,
|
|
entry=entry,
|
|
kind=kind,
|
|
ordinals=wrap(ordinals),
|
|
values=wrap(values),
|
|
timestamps=wrap(timestamps),
|
|
complete=True,
|
|
metadata={
|
|
"async_update_type": update_type,
|
|
"max_shape": list(max_shape),
|
|
"acquisition_group": None,
|
|
},
|
|
)
|
|
|
|
|
|
def _make_update(sources, scan_id="dummy", reason="live", group="scan"):
|
|
from bec_lib.data_api.models import SubscriptionUpdate
|
|
|
|
source_map = {source.key: source for source in sources}
|
|
ordinal_sets = [set(source.ordinals) for source in source_map.values()]
|
|
aligned = tuple(sorted(set.intersection(*ordinal_sets))) if ordinal_sets else ()
|
|
return SubscriptionUpdate(
|
|
scan_id=scan_id,
|
|
reason=reason,
|
|
sources=source_map,
|
|
aligned_ordinals=aligned,
|
|
complete=True,
|
|
metadata={"group": group},
|
|
)
|
|
|
|
|
|
def test_waveform_initialization(qtbot, mocked_client):
|
|
"""
|
|
Test that a new Waveform widget initializes with the correct defaults.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
assert wf.objectName() == "Waveform"
|
|
# Inherited from PlotBase
|
|
assert wf.title == ""
|
|
assert wf.x_label == ""
|
|
assert wf.y_label == ""
|
|
# No crosshair or FPS monitor by default
|
|
assert wf.crosshair is None
|
|
assert wf.fps_monitor is None
|
|
# No curves initially
|
|
assert len(wf.plot_item.curves) == 0
|
|
|
|
|
|
def test_waveform_with_side_menu(qtbot, mocked_client):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client, popups=False)
|
|
|
|
assert wf.ui_mode == UIMode.SIDE
|
|
|
|
|
|
def test_plot_custom_curve(qtbot, mocked_client):
|
|
"""
|
|
Test that calling plot with explicit x and y data creates a custom curve.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
curve = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
|
|
assert curve is not None
|
|
assert curve.config.source == "custom"
|
|
assert curve.config.label == "custom_curve"
|
|
x_data, y_data = curve.get_data()
|
|
np.testing.assert_array_equal(x_data, np.array([1, 2, 3]))
|
|
np.testing.assert_array_equal(y_data, np.array([4, 5, 6]))
|
|
|
|
|
|
def test_plot_single_arg_input_1d(qtbot, mocked_client):
|
|
"""
|
|
Test that when a single 1D numpy array is passed, the curve is created with
|
|
x-data as a generated index.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
data = np.array([10, 20, 30])
|
|
curve = wf.plot(data, label="curve_1d")
|
|
x_data, y_data = curve.get_data()
|
|
np.testing.assert_array_equal(x_data, np.arange(len(data)))
|
|
np.testing.assert_array_equal(y_data, data)
|
|
|
|
|
|
def test_plot_single_arg_input_2d(qtbot, mocked_client):
|
|
"""
|
|
Test that when a single 2D numpy array (N x 2) is passed,
|
|
x and y data are extracted from the first and second columns.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
data = np.array([[1, 4], [2, 5], [3, 6]])
|
|
curve = wf.plot(data, label="curve_2d")
|
|
x_data, y_data = curve.get_data()
|
|
np.testing.assert_array_equal(x_data, data[:, 0])
|
|
np.testing.assert_array_equal(y_data, data[:, 1])
|
|
|
|
|
|
def test_plot_single_arg_input_sync(qtbot, mocked_client):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
c1 = wf.plot(arg1="bpm4i")
|
|
c2 = wf.plot(arg1="bpm3a")
|
|
|
|
assert c1.config.source == "device"
|
|
assert c2.config.source == "device"
|
|
assert c1.config.signal == DeviceSignal(device="bpm4i", signal="bpm4i", dap=None)
|
|
assert c2.config.signal == DeviceSignal(device="bpm3a", signal="bpm3a", dap=None)
|
|
|
|
# Check that the curve is added to the plot
|
|
assert len(wf.plot_item.curves) == 2
|
|
|
|
|
|
def test_plot_single_arg_input_async(qtbot, mocked_client):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
c1 = wf.plot(arg1="eiger")
|
|
c2 = wf.plot(arg1="async_device")
|
|
|
|
assert c1.config.source == "device"
|
|
assert c2.config.source == "device"
|
|
assert c1.config.signal == DeviceSignal(device="eiger", signal="eiger", dap=None)
|
|
assert c2.config.signal == DeviceSignal(device="async_device", signal="async_device", dap=None)
|
|
|
|
# Check that the curve is added to the plot
|
|
assert len(wf.plot_item.curves) == 2
|
|
|
|
|
|
def test_curve_access_pattern(qtbot, mocked_client):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
c1 = wf.plot(arg1="bpm4i")
|
|
c2 = wf.plot(arg1="bpm3a")
|
|
|
|
# Check that the curve is added to the plot
|
|
assert len(wf.plot_item.curves) == 2
|
|
|
|
# Check that the curve is accessible by label
|
|
assert wf.get_curve("bpm4i-bpm4i") == c1
|
|
assert wf.get_curve("bpm3a-bpm3a") == c2
|
|
|
|
# Check that the curve is accessible by index
|
|
assert wf.get_curve(0) == c1
|
|
assert wf.get_curve(1) == c2
|
|
|
|
assert wf.curves[0] == c1
|
|
assert wf.curves[1] == c2
|
|
|
|
|
|
def test_find_curve_by_label(qtbot, mocked_client):
|
|
"""
|
|
Test the _find_curve_by_label method returns the correct curve or None if not found.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c1 = wf.plot(arg1="bpm4i", label="c1_label")
|
|
c2 = wf.plot(arg1="bpm3a", label="c2_label")
|
|
|
|
found = wf._find_curve_by_label("c1_label")
|
|
assert found == c1, "Should return the first curve"
|
|
missing = wf._find_curve_by_label("bogus_label")
|
|
assert missing is None, "Should return None if not found"
|
|
|
|
|
|
def test_set_x_mode(qtbot, mocked_client):
|
|
"""
|
|
Test that setting x_mode updates the internal x-axis mode state and switches
|
|
the bottom axis of the plot.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.x_mode = "timestamp"
|
|
assert wf.x_axis_mode["name"] == "timestamp"
|
|
# When x_mode is 'timestamp', the bottom axis should be a DateAxisItem.
|
|
assert isinstance(wf.plot_item.axes["bottom"]["item"], DateAxisItem)
|
|
|
|
wf.x_mode = "index"
|
|
# For other modes, the bottom axis becomes the default AxisItem.
|
|
assert isinstance(wf.plot_item.axes["bottom"]["item"], pg.AxisItem)
|
|
|
|
wf.x_mode = "samx"
|
|
assert wf.x_axis_mode["name"] == "samx"
|
|
assert isinstance(wf.plot_item.axes["bottom"]["item"], pg.AxisItem)
|
|
|
|
|
|
def test_color_palette_update(qtbot, mocked_client):
|
|
"""
|
|
Test that updating the color_palette property changes the color of existing curves.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
curve = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
|
|
original_color = curve.config.color
|
|
# Change to a different valid palette
|
|
wf.color_palette = "magma"
|
|
assert wf.config.color_palette == "magma"
|
|
# After updating the palette, the curve's color should be re-generated.
|
|
assert curve.config.color != original_color
|
|
|
|
|
|
def test_curve_json_property(qtbot, mocked_client):
|
|
"""
|
|
Test that the curve_json property returns a JSON string representing
|
|
non-custom curves. Since custom curves are not serialized, if only a custom
|
|
curve is added, an empty list should be returned.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
|
|
json_str = wf.curve_json
|
|
data = json.loads(json_str)
|
|
assert isinstance(data, list)
|
|
# Only custom curves exist so none should be serialized.
|
|
assert len(data) == 0
|
|
|
|
|
|
def test_remove_curve_waveform(qtbot, mocked_client):
|
|
"""
|
|
Test that curves can be removed from the waveform using either their label or index.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve1")
|
|
wf.plot(x=[4, 5, 6], y=[7, 8, 9], label="curve2")
|
|
num_before = len(wf.plot_item.curves)
|
|
wf.remove_curve("curve1")
|
|
num_after = len(wf.plot_item.curves)
|
|
assert num_after == num_before - 1
|
|
|
|
wf.remove_curve(0)
|
|
assert len(wf.plot_item.curves) == num_after - 1
|
|
|
|
|
|
def test_get_all_data_empty(qtbot, mocked_client):
|
|
"""
|
|
Test that get_all_data returns an empty dictionary when no curves have been added.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
all_data = wf.get_all_data(output="dict")
|
|
assert all_data == {}
|
|
|
|
|
|
def test_get_all_data_dict(qtbot, mocked_client):
|
|
"""
|
|
Test that get_all_data returns a dictionary with the expected x and y data for each curve.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve1")
|
|
wf.plot(x=[7, 8, 9], y=[10, 11, 12], label="curve2")
|
|
|
|
all_data = wf.get_all_data(output="dict")
|
|
|
|
expected = {
|
|
"curve1": {"x": [1, 2, 3], "y": [4, 5, 6]},
|
|
"curve2": {"x": [7, 8, 9], "y": [10, 11, 12]},
|
|
}
|
|
assert all_data == expected
|
|
|
|
|
|
def test_curve_json_getter_setter(qtbot, mocked_client):
|
|
"""
|
|
Test that the curve_json getter returns a JSON string representing device curves
|
|
and that setting curve_json re-creates the curves.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
# These curves should be in JSON
|
|
wf.plot(arg1="bpm4i")
|
|
wf.plot(arg1="bpm3a")
|
|
# Custom curves should be ignored
|
|
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
|
|
wf.plot([1, 2, 3, 4])
|
|
|
|
# Get JSON from the getter.
|
|
json_str = wf.curve_json
|
|
curve_configs = json.loads(json_str)
|
|
# Only device curves are serialized; expect two configurations.
|
|
assert isinstance(curve_configs, list)
|
|
assert len(curve_configs) == 2
|
|
labels = [cfg["label"] for cfg in curve_configs]
|
|
assert "bpm4i-bpm4i" in labels
|
|
assert "bpm3a-bpm3a" in labels
|
|
|
|
# Clear all curves.
|
|
wf.clear_all()
|
|
assert len(wf.plot_item.curves) == 0
|
|
|
|
# Use the JSON setter to re-create the curves.
|
|
wf.curve_json = json_str
|
|
# After setting, the waveform should have two curves.
|
|
assert len(wf.plot_item.curves) == 2
|
|
new_labels = [curve.name() for curve in wf.plot_item.curves]
|
|
for lab in labels:
|
|
assert lab in new_labels
|
|
|
|
|
|
def test_curve_json_setter_ignores_custom(qtbot, mocked_client):
|
|
"""
|
|
Test that when curve_json setter is given a JSON string containing a
|
|
curve with source "custom", that curve is not added.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
device_curve_config = {
|
|
"widget_class": "Curve",
|
|
"parent_id": wf.gui_id,
|
|
"label": "device_curve",
|
|
"color": "#ff0000",
|
|
"source": "device",
|
|
"signal": {"device": "bpm4i", "signal": "bpm4i", "dap": None},
|
|
}
|
|
custom_curve_config = {
|
|
"widget_class": "Curve",
|
|
"parent_id": wf.gui_id,
|
|
"label": "custom_curve",
|
|
"color": "#00ff00",
|
|
"source": "custom",
|
|
# No signal for custom curves.
|
|
}
|
|
json_str = json.dumps([device_curve_config, custom_curve_config], indent=2)
|
|
wf.curve_json = json_str
|
|
# Only the device curve should be added.
|
|
curves = wf.plot_item.curves
|
|
assert len(curves) == 1
|
|
assert curves[0].name() == "device_curve"
|
|
|
|
|
|
##################################################
|
|
# Waveform widget scan logic tests
|
|
##################################################
|
|
|
|
|
|
def test_on_data_update_sync_timestamp_mode(monkeypatch, qtbot, mocked_client):
|
|
"""
|
|
A monitored curve in timestamp mode is rendered from the aligned columns
|
|
against the source timestamps.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(arg1="bpm4i")
|
|
wf.x_mode = "timestamp"
|
|
wf.scan_id = "dummy"
|
|
|
|
update = _make_update(
|
|
[_monitored_source("bpm4i", values=(5, 6, 7), timestamps=(101, 201, 301))]
|
|
)
|
|
wf._on_data_update(update)
|
|
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [101, 201, 301])
|
|
np.testing.assert_array_equal(y_data, [5, 6, 7])
|
|
|
|
|
|
def test_on_data_update_sync_index_mode(monkeypatch, qtbot, mocked_client):
|
|
"""
|
|
A monitored curve in index mode is rendered against the aligned ordinals.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(arg1="bpm4i")
|
|
wf.x_mode = "index"
|
|
wf.scan_id = "dummy"
|
|
|
|
wf._on_data_update(_make_update([_monitored_source("bpm4i", values=(5, 6, 7))]))
|
|
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [0, 1, 2])
|
|
np.testing.assert_array_equal(y_data, [5, 6, 7])
|
|
|
|
|
|
def test_on_data_update_sync_custom_x_device(monkeypatch, qtbot, mocked_client):
|
|
"""
|
|
A monitored curve in custom device mode uses the aligned values of the x
|
|
device delivered in the same update; a missing x source falls back to
|
|
the index.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(arg1="bpm4i")
|
|
wf.x_mode = "samx"
|
|
wf.scan_id = "dummy"
|
|
|
|
update = _make_update(
|
|
[
|
|
_monitored_source("bpm4i", values=(5, 6, 7)),
|
|
_monitored_source("samx", values=(50, 60, 70)),
|
|
]
|
|
)
|
|
wf._on_data_update(update)
|
|
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [50, 60, 70])
|
|
np.testing.assert_array_equal(y_data, [5, 6, 7])
|
|
assert wf._current_x_device == ("samx", "samx")
|
|
|
|
# X source missing from the update -> index fallback
|
|
wf._on_data_update(_make_update([_monitored_source("bpm4i", values=(5, 6, 7))]))
|
|
x_data, _ = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [0, 1, 2])
|
|
|
|
|
|
def test_on_data_update_sync_auto_mode(monkeypatch, qtbot, mocked_client):
|
|
"""
|
|
Auto mode resolves the x device from the scan report devices when no
|
|
async curves are present, and falls back to the index otherwise.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(arg1="bpm4i")
|
|
wf.scan_id = "dummy"
|
|
wf.scan_item = create_dummy_scan_item() # scan_report_devices == ["samx"]
|
|
|
|
update = _make_update(
|
|
[
|
|
_monitored_source("bpm4i", values=(5, 6, 7)),
|
|
_monitored_source("samx", values=(10, 20, 30)),
|
|
]
|
|
)
|
|
wf._on_data_update(update)
|
|
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [10, 20, 30])
|
|
np.testing.assert_array_equal(y_data, [5, 6, 7])
|
|
assert wf._current_x_device == ("samx", "samx")
|
|
|
|
# With an async curve present, auto mode falls back to index.
|
|
wf._async_curves = [MagicMock()]
|
|
wf._on_data_update(update)
|
|
x_data, _ = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [0, 1, 2])
|
|
assert wf._current_x_device is None
|
|
|
|
|
|
def test_categorise_device_curves(monkeypatch, qtbot, mocked_client):
|
|
"""
|
|
Test that _categorise_device_curves correctly categorizes curves.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
dummy_scan = create_dummy_scan_item()
|
|
wf.scan_item = dummy_scan
|
|
|
|
c_sync = wf.plot(arg1="bpm4i", label="bpm4i-bpm4i")
|
|
c_async = wf.plot(arg1="async_device", label="async_device-async_device")
|
|
|
|
mode = wf._categorise_device_curves()
|
|
|
|
assert mode == "mixed"
|
|
assert c_sync in wf._sync_curves
|
|
assert c_async in wf._async_curves
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["sync", "async", "mixed"])
|
|
def test_on_scan_status(qtbot, mocked_client, monkeypatch, mode):
|
|
"""
|
|
Test that on_scan_status performs the per-scan bookkeeping (scan id,
|
|
categorisation) and rebuilds the DataAPI subscription for the new scan.
|
|
"""
|
|
bridges = _fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
# Force creation of a couple of device curves
|
|
if mode == "sync":
|
|
wf.plot(arg1="bpm4i")
|
|
elif mode == "async":
|
|
wf.plot(arg1="async_device")
|
|
else:
|
|
wf.plot(arg1="bpm4i")
|
|
wf.plot(arg1="async_device")
|
|
|
|
# We mock out the scan_item, pretending we found a new scan.
|
|
dummy_scan = create_dummy_scan_item()
|
|
dummy_scan.metadata["bec"]["scan_id"] = "1234"
|
|
monkeypatch.setattr(wf.queue.scan_storage, "find_scan_by_ID", lambda scan_id: dummy_scan)
|
|
|
|
n_bridges = len(bridges)
|
|
wf.on_scan_status({"scan_id": "1234"}, {})
|
|
|
|
assert wf.scan_id == "1234"
|
|
assert wf.scan_item == dummy_scan
|
|
assert wf._mode == mode
|
|
|
|
# The DataAPI subscription is rebuilt for the new scan and follows it live.
|
|
assert len(bridges) > n_bridges
|
|
assert bridges[-1].scan == "live"
|
|
expected_sources = []
|
|
if mode in ("sync", "mixed"):
|
|
expected_sources.append(("bpm4i", "bpm4i"))
|
|
if mode in ("async", "mixed"):
|
|
expected_sources.append(("async_device", "async_device"))
|
|
for key in expected_sources:
|
|
assert key in bridges[-1].sources
|
|
if mode == "sync":
|
|
# Auto mode resolves the x device from the scan report devices.
|
|
assert ("samx", "samx") in bridges[-1].sources
|
|
|
|
|
|
def test_on_scan_status_ignored_without_device_curves(qtbot, mocked_client_with_dap, monkeypatch):
|
|
"""
|
|
A widget with only custom/dap curves (no live scan to follow) must not have its
|
|
scan_id -- and therefore its DAP request/response subscription -- reassigned by
|
|
unrelated scan_status messages. Otherwise an in-flight DAP request can have its
|
|
response dropped because the widget resubscribed to a different scan_id before
|
|
the response arrived.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
x = np.linspace(-1, 1, 50)
|
|
y = np.sin(x)
|
|
wf.plot(x=x, y=y, label="custom-curve", dap="GaussianModel")
|
|
|
|
dummy_scan = create_dummy_scan_item()
|
|
dummy_scan.metadata["bec"]["scan_id"] = "unrelated-scan-1"
|
|
monkeypatch.setattr(wf.queue.scan_storage, "find_scan_by_ID", lambda scan_id: dummy_scan)
|
|
|
|
setup_dap_spy = MagicMock(wraps=wf.setup_dap_for_scan)
|
|
monkeypatch.setattr(wf, "setup_dap_for_scan", setup_dap_spy)
|
|
|
|
scan_id_before = wf.scan_id
|
|
calls_before = setup_dap_spy.call_count
|
|
|
|
wf.on_scan_status({"scan_id": "unrelated-scan-1"}, {})
|
|
wf.on_scan_status({"scan_id": "unrelated-scan-2"}, {})
|
|
|
|
assert wf.scan_id == scan_id_before
|
|
assert setup_dap_spy.call_count == calls_before
|
|
|
|
|
|
def test_request_dap_skips_unchanged_static_parent(qtbot, mocked_client_with_dap, monkeypatch):
|
|
"""
|
|
DAP curves whose parent is a static custom curve must not be resubmitted by
|
|
scan-driven request_dap calls when the fit inputs are unchanged. Changing the
|
|
custom data (or the oversample) triggers exactly one new request.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
curve = wf.plot(x=[0, 1, 2], y=[1, 2, 3], label="custom-static", dap="GaussianModel")
|
|
dap_curve = wf.get_curve(f"{curve.name()}-GaussianModel")
|
|
assert dap_curve is not None
|
|
|
|
published = []
|
|
monkeypatch.setattr(
|
|
wf.client.connector,
|
|
"set_and_publish",
|
|
lambda topic, msg, *args, **kwargs: published.append(msg),
|
|
)
|
|
|
|
# The creation-time request already stored a fingerprint; identical inputs are skipped
|
|
wf.request_dap()
|
|
wf.request_dap()
|
|
assert len(published) == 0
|
|
|
|
# New custom data -> one new request, further identical calls skipped again
|
|
curve.set_data([0, 1, 2], [3, 2, 1])
|
|
wf.request_dap()
|
|
wf.request_dap()
|
|
assert len(published) == 1
|
|
|
|
# Oversample change requests immediately via the setter and updates the fingerprint
|
|
dap_curve.dap_oversample = 4
|
|
assert len(published) == 2
|
|
wf.request_dap()
|
|
assert len(published) == 2
|
|
|
|
|
|
def test_request_dap_resubmits_on_roi_change_for_static_parent(
|
|
qtbot, mocked_client_with_dap, monkeypatch
|
|
):
|
|
"""
|
|
Changing the linear region selector changes the cropped fit inputs, so a DAP
|
|
curve with a static custom parent must be resubmitted even though the parent
|
|
data itself did not change.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
x = np.linspace(0, 10, 50)
|
|
wf.plot(x=x, y=np.sin(x), label="custom-roi", dap="GaussianModel")
|
|
|
|
published = []
|
|
monkeypatch.setattr(
|
|
wf.client.connector,
|
|
"set_and_publish",
|
|
lambda topic, msg, *args, **kwargs: published.append(msg),
|
|
)
|
|
|
|
wf.request_dap()
|
|
assert len(published) == 0
|
|
|
|
wf.roi_region = (2.0, 8.0)
|
|
wf.request_dap()
|
|
assert len(published) == 1
|
|
assert len(published[0].content["config"]["kwargs"]["data_x"]) < len(x)
|
|
|
|
# Same region again -> no resubmission
|
|
wf.request_dap()
|
|
assert len(published) == 1
|
|
|
|
# Removing the region restores the full data set -> one resubmission
|
|
wf.roi_region = None
|
|
wf.request_dap()
|
|
assert len(published) == 2
|
|
|
|
|
|
def test_request_dap_releases_proxy_when_nothing_published(
|
|
qtbot, mocked_client_with_dap, monkeypatch
|
|
):
|
|
"""
|
|
When request_dap skips every DAP curve (static parents, unchanged inputs), no
|
|
dap_response will arrive to unblock proxy_dap_request. The proxy must be
|
|
released immediately, otherwise the next trigger (e.g. an ROI change) would be
|
|
delayed by the proxy timeout.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
wf.plot(x=[0, 1, 2], y=[1, 2, 3], label="custom-blocked", dap="GaussianModel")
|
|
|
|
monkeypatch.setattr(
|
|
wf.client.connector, "set_and_publish", lambda topic, msg, *args, **kwargs: None
|
|
)
|
|
|
|
wf.request_dap_update.emit()
|
|
assert wf.proxy_dap_request.blocked is True
|
|
# The proxy timeout is 10 s; the no-publish call must release it much earlier
|
|
qtbot.waitUntil(lambda: wf.proxy_dap_request.blocked is False, timeout=3000)
|
|
|
|
|
|
def test_request_dap_always_resubmits_device_parent(qtbot, mocked_client_with_dap, monkeypatch):
|
|
"""
|
|
DAP curves attached to device curves keep the resubmit-on-every-update behavior,
|
|
since their parent data changes as the scan progresses.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
wf.plot(arg1="bpm4i", label="bpm4i-bpm4i", dap="GaussianModel")
|
|
|
|
published = []
|
|
monkeypatch.setattr(
|
|
wf.client.connector,
|
|
"set_and_publish",
|
|
lambda topic, msg, *args, **kwargs: published.append(msg),
|
|
)
|
|
|
|
wf.request_dap()
|
|
wf.request_dap()
|
|
assert len(published) == 2
|
|
|
|
|
|
def test_add_dap_curve(qtbot, mocked_client_with_dap, monkeypatch):
|
|
"""
|
|
Test add_dap_curve creates a new DAP curve from an existing device curve
|
|
and verifies that the DAP call doesn't fail due to mock-based plugin_info.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
wf.plot(arg1="bpm4i", label="bpm4i-bpm4i")
|
|
|
|
dap_curve = wf.add_dap_curve(device_label="bpm4i-bpm4i", dap_name="GaussianModel")
|
|
assert dap_curve is not None
|
|
assert dap_curve.config.source == "dap"
|
|
assert dap_curve.config.signal.device == "bpm4i"
|
|
assert dap_curve.config.signal.dap == "GaussianModel"
|
|
|
|
|
|
def test_add_dap_curve_custom_source(qtbot, mocked_client_with_dap):
|
|
"""
|
|
Ensure that custom curves can also serve as parents for DAP fits.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
x = np.linspace(-1, 1, 50)
|
|
y = np.sin(x)
|
|
custom_curve = wf.plot(x=x, y=y, label="custom-curve")
|
|
|
|
dap_curve = wf.add_dap_curve(device_label=custom_curve.name(), dap_name="GaussianModel")
|
|
assert dap_curve.config.source == "dap"
|
|
assert dap_curve.config.parent_label == custom_curve.name()
|
|
assert dap_curve.config.signal.device == custom_curve.name()
|
|
assert dap_curve.config.signal.signal == "custom"
|
|
assert dap_curve.config.signal.dap == "GaussianModel"
|
|
|
|
|
|
def test_alignment_mode_toggle_shows_bottom_panel(qtbot, mocked_client):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
action = wf.toolbar.components.get_action("alignment_mode").action
|
|
action.trigger()
|
|
|
|
assert wf._alignment_panel_visible is True
|
|
assert wf._alignment_side_panel.panel_visible is True
|
|
assert action.isChecked() is True
|
|
|
|
action.trigger()
|
|
|
|
assert wf._alignment_panel_visible is False
|
|
assert wf._alignment_side_panel.panel_visible is False
|
|
assert action.isChecked() is False
|
|
|
|
|
|
def test_resolve_alignment_positioner(qtbot, mocked_client):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
wf.x_mode = "samx"
|
|
assert wf._resolve_alignment_positioner() == "samx"
|
|
|
|
wf.x_mode = "auto"
|
|
wf._current_x_device = ("samx", "samx")
|
|
assert wf._resolve_alignment_positioner() == "samx"
|
|
|
|
wf._current_x_device = ("bpm4i", "bpm4i")
|
|
assert wf._resolve_alignment_positioner() is None
|
|
|
|
wf.x_mode = "index"
|
|
assert wf._resolve_alignment_positioner() is None
|
|
|
|
wf.x_mode = "timestamp"
|
|
assert wf._resolve_alignment_positioner() is None
|
|
|
|
|
|
def test_alignment_panel_updates_when_auto_x_motor_changes(
|
|
qtbot, mocked_client_with_dap, monkeypatch
|
|
):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
|
wf.x_mode = "auto"
|
|
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
|
|
|
wf._current_x_device = ("samx", "samx")
|
|
wf._alignment_panel.set_positioner_device("samx")
|
|
wf.scan_item = create_dummy_scan_item()
|
|
# A real scan carries the report devices on its (in-memory) status
|
|
# message; the data file is only the last-resort source.
|
|
wf.scan_item.status_message.info["scan_report_devices"] = ["samy"]
|
|
wf.scan_item.metadata["bec"]["scan_report_devices"] = ["samy"]
|
|
|
|
# Rendering a DataAPI update re-resolves the auto x device from the scan
|
|
# report devices and refreshes the alignment state.
|
|
wf._resolve_x_axis()
|
|
|
|
assert wf._current_x_device == ("samy", "samy")
|
|
assert wf._alignment_positioner_name == "samy"
|
|
assert wf._alignment_panel.positioner.device == "samy"
|
|
|
|
|
|
def test_alignment_panel_disables_without_positioner(qtbot, mocked_client_with_dap):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
wf.plot(arg1="bpm4i")
|
|
wf.x_mode = "index"
|
|
|
|
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
|
|
|
assert wf._alignment_panel.positioner.isEnabled() is False
|
|
assert "positioner on the x axis" in wf._alignment_panel.status_label.text()
|
|
|
|
|
|
def test_alignment_marker_updates_from_positioner_readback(qtbot, mocked_client_with_dap):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
|
wf.x_mode = "samx"
|
|
|
|
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
|
wf.dev["samx"].signals["samx"]["value"] = 4.2
|
|
wf._alignment_panel.positioner.force_update_readback()
|
|
|
|
assert wf._alignment_controller is not None
|
|
assert wf._alignment_controller.marker_line is not None
|
|
assert np.isclose(wf._alignment_controller.marker_line.value(), 4.2)
|
|
assert "samx" in wf._alignment_controller.marker_line.label.toPlainText()
|
|
assert "4.200" in wf._alignment_controller.marker_line.label.toPlainText()
|
|
|
|
|
|
def test_alignment_panel_uses_existing_dap_curves_and_moves_positioner(
|
|
qtbot, mocked_client_with_dap
|
|
):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
source_curve = wf.plot(arg1="bpm4i")
|
|
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
|
|
wf.x_mode = "samx"
|
|
|
|
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
|
fit_summary = make_alignment_fit_summary(center=2.5)
|
|
wf.dap_summary_update.emit(fit_summary, {"curve_id": dap_curve.name()})
|
|
wf._alignment_panel.fit_dialog.select_curve(dap_curve.name())
|
|
|
|
move_spy = MagicMock()
|
|
wf.dev["samx"].move = move_spy
|
|
|
|
assert wf._alignment_panel.fit_dialog.fit_curve_id == dap_curve.name()
|
|
assert wf._alignment_panel.fit_dialog.action_buttons["center"].isEnabled() is True
|
|
|
|
wf._alignment_panel.fit_dialog.action_buttons["center"].click()
|
|
|
|
move_spy.assert_called_once_with(2.5, relative=False)
|
|
|
|
|
|
def test_alignment_target_line_toggle_updates_target_value_label(qtbot, mocked_client_with_dap):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
|
wf.x_mode = "samx"
|
|
|
|
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
|
wf._alignment_panel.target_toggle.setChecked(True)
|
|
|
|
assert wf._alignment_controller is not None
|
|
assert wf._alignment_controller.target_line is not None
|
|
assert wf._alignment_panel.move_to_target_button.isEnabled() is True
|
|
|
|
wf._alignment_controller.target_line.setValue(1.5)
|
|
|
|
assert "1.500" in wf._alignment_panel.target_toggle.text()
|
|
|
|
|
|
def test_alignment_move_to_target_uses_draggable_line_value(qtbot, mocked_client_with_dap):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
|
wf.x_mode = "samx"
|
|
|
|
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
|
wf._alignment_panel.target_toggle.setChecked(True)
|
|
wf._alignment_controller.target_line.setValue(1.25)
|
|
|
|
move_spy = MagicMock()
|
|
wf.dev["samx"].move = move_spy
|
|
|
|
wf._alignment_panel.move_to_target_button.click()
|
|
|
|
move_spy.assert_called_once_with(1.25, relative=False)
|
|
|
|
|
|
def test_alignment_mode_toggle_off_keeps_user_dap_curve(qtbot, mocked_client_with_dap):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
source_curve = wf.plot(arg1="bpm4i")
|
|
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
|
|
wf.x_mode = "samx"
|
|
|
|
action = wf.toolbar.components.get_action("alignment_mode").action
|
|
action.trigger()
|
|
action.trigger()
|
|
|
|
assert wf.get_curve(dap_curve.name()) is not None
|
|
|
|
|
|
def test_alignment_mode_toggle_off_clears_controller_overlays(qtbot, mocked_client_with_dap):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
|
wf.x_mode = "samx"
|
|
|
|
action = wf.toolbar.components.get_action("alignment_mode").action
|
|
action.trigger()
|
|
wf._alignment_panel.target_toggle.setChecked(True)
|
|
wf.dev["samx"].signals["samx"]["value"] = 2.0
|
|
wf._alignment_panel.positioner.force_update_readback()
|
|
|
|
assert wf._alignment_controller.marker_line is not None
|
|
assert wf._alignment_controller.target_line is not None
|
|
|
|
action.trigger()
|
|
|
|
assert wf._alignment_controller.marker_line is None
|
|
assert wf._alignment_controller.target_line is None
|
|
|
|
|
|
def test_alignment_panel_removes_deleted_dap_curve_from_fit_list(qtbot, mocked_client_with_dap):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
source_curve = wf.plot(arg1="bpm4i")
|
|
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
|
|
|
|
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
|
wf.dap_summary_update.emit(
|
|
make_alignment_fit_summary(center=1.5), {"curve_id": dap_curve.name()}
|
|
)
|
|
|
|
assert dap_curve.name() in wf._alignment_panel.fit_dialog.summary_data
|
|
|
|
wf.remove_curve(dap_curve.name())
|
|
|
|
assert dap_curve.name() not in wf._alignment_panel.fit_dialog.summary_data
|
|
|
|
|
|
def test_alignment_controller_move_request_moves_positioner(qtbot, mocked_client_with_dap):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
|
wf.x_mode = "samx"
|
|
|
|
move_spy = MagicMock()
|
|
wf.dev["samx"].move = move_spy
|
|
|
|
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
|
wf._alignment_controller.move_absolute_requested.emit(3.5)
|
|
|
|
move_spy.assert_called_once_with(3.5, relative=False)
|
|
|
|
|
|
def test_curve_set_data_emits_dap_update(qtbot, mocked_client):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
|
|
with qtbot.waitSignal(wf.request_dap_update):
|
|
c.set_data([7, 8, 9], [10, 11, 12])
|
|
|
|
|
|
def test_plot_custom_curve_with_inline_dap(qtbot, mocked_client_with_dap):
|
|
"""
|
|
Supplying the `dap` kwarg when plotting custom data should auto-create the fit curve.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
curve = wf.plot(x=[0, 1, 2], y=[1, 2, 3], label="custom-inline", dap="GaussianModel")
|
|
|
|
dap_curve = wf.get_curve(f"{curve.name()}-GaussianModel")
|
|
assert dap_curve is not None
|
|
assert dap_curve.config.parent_label == curve.name()
|
|
assert dap_curve.config.signal.dap == "GaussianModel"
|
|
|
|
|
|
def test_normalize_dap_parameters_number_dict():
|
|
normalized = Waveform._normalize_dap_parameters({"amplitude": 1.0, "center": 2})
|
|
assert normalized == {
|
|
"amplitude": {"name": "amplitude", "value": 1.0, "vary": False},
|
|
"center": {"name": "center", "value": 2.0, "vary": False},
|
|
}
|
|
|
|
|
|
def test_normalize_dap_parameters_dict_spec_defaults_vary_false():
|
|
normalized = Waveform._normalize_dap_parameters({"sigma": {"value": 0.8, "min": 0.0}})
|
|
assert normalized["sigma"]["name"] == "sigma"
|
|
assert normalized["sigma"]["value"] == 0.8
|
|
assert normalized["sigma"]["min"] == 0.0
|
|
assert normalized["sigma"]["vary"] is False
|
|
|
|
|
|
def test_normalize_dap_parameters_invalid_type_raises():
|
|
with pytest.raises(TypeError):
|
|
Waveform._normalize_dap_parameters(["amplitude", 1.0]) # type: ignore[arg-type]
|
|
|
|
|
|
def test_normalize_dap_parameters_composite_list():
|
|
normalized = Waveform._normalize_dap_parameters(
|
|
[{"center": 1.0}, {"sigma": {"value": 0.5, "min": 0.0}}],
|
|
dap_name=["GaussianModel", "GaussianModel"],
|
|
)
|
|
assert normalized == [
|
|
{"center": {"name": "center", "value": 1.0, "vary": False}},
|
|
{"sigma": {"name": "sigma", "value": 0.5, "min": 0.0, "vary": False}},
|
|
]
|
|
|
|
|
|
def test_normalize_dap_parameters_composite_dict():
|
|
normalized = Waveform._normalize_dap_parameters(
|
|
{
|
|
"GaussianModel": {"center": {"value": 1.0, "vary": True}},
|
|
"LorentzModel": {"amplitude": 2.0},
|
|
},
|
|
dap_name=["GaussianModel", "LorentzModel"],
|
|
)
|
|
assert normalized["GaussianModel"]["center"]["value"] == 1.0
|
|
assert normalized["GaussianModel"]["center"]["vary"] is True
|
|
assert normalized["LorentzModel"]["amplitude"]["value"] == 2.0
|
|
assert normalized["LorentzModel"]["amplitude"]["vary"] is False
|
|
|
|
|
|
def test_request_dap_includes_normalized_parameters(qtbot, mocked_client_with_dap, monkeypatch):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
|
|
captured = {}
|
|
|
|
def capture(topic, msg, *args, **kwargs): # noqa: ARG001
|
|
captured["topic"] = topic
|
|
captured["msg"] = msg
|
|
|
|
monkeypatch.setattr(wf.client.connector, "set_and_publish", capture)
|
|
|
|
curve = wf.plot(
|
|
x=[0, 1, 2],
|
|
y=[1, 2, 3],
|
|
label="custom-inline-params",
|
|
dap="GaussianModel",
|
|
dap_parameters={"amplitude": 1.0},
|
|
)
|
|
dap_curve = wf.get_curve(f"{curve.name()}-GaussianModel")
|
|
assert dap_curve is not None
|
|
# The oversample setter issues a fresh DAP request with the new value
|
|
dap_curve.dap_oversample = 3
|
|
|
|
msg = captured["msg"]
|
|
dap_kwargs = msg.content["config"]["kwargs"]
|
|
assert dap_kwargs["oversample"] == 3
|
|
assert dap_kwargs["parameters"] == {
|
|
"amplitude": {"name": "amplitude", "value": 1.0, "vary": False}
|
|
}
|
|
|
|
|
|
def test_request_dap_includes_composite_parameters_list(qtbot, mocked_client_with_dap, monkeypatch):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
|
|
|
captured = {}
|
|
|
|
def capture(topic, msg, *args, **kwargs): # noqa: ARG001
|
|
captured["topic"] = topic
|
|
captured["msg"] = msg
|
|
|
|
monkeypatch.setattr(wf.client.connector, "set_and_publish", capture)
|
|
|
|
curve = wf.plot(
|
|
x=[0, 1, 2],
|
|
y=[1, 2, 3],
|
|
label="custom-composite",
|
|
dap=["GaussianModel", "GaussianModel"],
|
|
dap_parameters=[{"center": 0.0}, {"center": 1.0}],
|
|
)
|
|
dap_curve = wf.get_curve(f"{curve.name()}-GaussianModel+GaussianModel")
|
|
assert dap_curve is not None
|
|
|
|
msg = captured["msg"]
|
|
dap_kwargs = msg.content["config"]["kwargs"]
|
|
assert dap_kwargs["parameters"] == [
|
|
{"center": {"name": "center", "value": 0.0, "vary": False}},
|
|
{"center": {"name": "center", "value": 1.0, "vary": False}},
|
|
]
|
|
assert msg.content["config"]["class_kwargs"]["model"] == ["GaussianModel", "GaussianModel"]
|
|
|
|
|
|
# NOTE: the legacy pull path (_fetch_scan_data_and_access, update_sync_curves,
|
|
# update_async_curves, _setup_async_curve, on_async_readback, _get_x_data) was
|
|
# replaced by the DataAPI subscription; its behaviour is asserted through the
|
|
# _on_data_update tests below.
|
|
|
|
|
|
def test_async_curve_sources_in_subscription(qtbot, mocked_client, monkeypatch):
|
|
"""
|
|
Async curves are served by the DataAPI subscription (the backend resolves
|
|
the async endpoints); their (device, entry) pairs are part of the bridge
|
|
sources and are not duplicated.
|
|
"""
|
|
bridges = _fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
wf.plot(arg1="async_device", label="async_device-async_device")
|
|
first_bridge = bridges[-1]
|
|
assert ("async_device", "async_device") in first_bridge.sources
|
|
|
|
# A second curve on the same signal replaces the subscription without
|
|
# duplicating the source.
|
|
wf.plot(arg1="async_device", label="second-curve")
|
|
assert first_bridge.closed is True
|
|
assert bridges[-1].sources.count(("async_device", "async_device")) == 1
|
|
|
|
|
|
def test_on_data_update_async_add(qtbot, mocked_client, monkeypatch):
|
|
"""
|
|
An async 'add' source concatenates its fragments (1-D max_shape); a
|
|
'replace' source displays only the current full state.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
|
wf.scan_id = "dummy"
|
|
wf.x_axis_mode["name"] = "index"
|
|
|
|
# 'add': fragments accumulate into one displayed series
|
|
source = _async_source(
|
|
"async_device", values=([10, 11, 12], [100, 200]), update_type="add", max_shape=(None,)
|
|
)
|
|
wf._on_data_update(_make_update([source]))
|
|
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [0, 1, 2, 3, 4])
|
|
np.testing.assert_array_equal(y_data, [10, 11, 12, 100, 200])
|
|
|
|
# 'replace': only the last full state is shown
|
|
source = _async_source("async_device", values=([999],), update_type="replace")
|
|
wf._on_data_update(_make_update([source]))
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [0])
|
|
np.testing.assert_array_equal(y_data, [999])
|
|
|
|
|
|
def test_on_data_update_async_add_2d(qtbot, mocked_client, monkeypatch):
|
|
"""
|
|
An async 'add' source with a 2-D max_shape displays the latest waveform.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
|
wf.scan_id = "dummy"
|
|
wf.x_axis_mode["name"] = "index"
|
|
|
|
source = _async_source(
|
|
"async_device", values=([1, 2, 3], [4, 5, 6]), update_type="add", max_shape=(None, 3)
|
|
)
|
|
wf._on_data_update(_make_update([source]))
|
|
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [0, 1, 2])
|
|
np.testing.assert_array_equal(y_data, [4, 5, 6])
|
|
|
|
|
|
def test_on_data_update_async_add_slice(qtbot, mocked_client, monkeypatch):
|
|
"""
|
|
An async 'add_slice' source displays the last accumulated row. Small rows
|
|
keep the symbol, large rows activate the downsampling settings.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
|
wf.scan_id = "dummy"
|
|
wf.x_axis_mode["name"] = "index"
|
|
|
|
# Small accumulated row: symbol stays, no downsampling
|
|
source = _async_source(
|
|
"async_device",
|
|
values=([100] * 10,),
|
|
ordinals=(0,),
|
|
update_type="add_slice",
|
|
max_shape=(None, 10),
|
|
)
|
|
wf._on_data_update(_make_update([source]))
|
|
x_data, y_data = c.get_data()
|
|
assert len(y_data) == 10
|
|
assert len(x_data) == 10
|
|
assert c.opts["symbol"] == "o"
|
|
|
|
# Large accumulated row: symbol removed, downsampling active
|
|
waveform_shape = 100000
|
|
source = _async_source(
|
|
"async_device",
|
|
values=(np.arange(waveform_shape),),
|
|
ordinals=(0,),
|
|
update_type="add_slice",
|
|
max_shape=(None, waveform_shape),
|
|
)
|
|
wf._on_data_update(_make_update([source]))
|
|
x_data, y_data = c.get_data()
|
|
assert len(y_data) == waveform_shape
|
|
assert len(x_data) == waveform_shape
|
|
assert c.opts["symbol"] is None
|
|
displayed_x, displayed_y = c.getData()
|
|
assert len(displayed_y) == len(displayed_x)
|
|
|
|
|
|
def test_on_data_update_async_timestamp_mode(qtbot, mocked_client, monkeypatch):
|
|
"""
|
|
Async curves in timestamp mode use the source timestamps when they match
|
|
the displayed length and fall back to index otherwise.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
|
wf.scan_id = "dummy"
|
|
wf.x_axis_mode["name"] = "timestamp"
|
|
|
|
# One timestamp per single-sample fragment: lengths match -> timestamps
|
|
source = _async_source(
|
|
"async_device", values=([1], [2], [3]), timestamps=(11, 21, 31), update_type="add"
|
|
)
|
|
wf._on_data_update(_make_update([source]))
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [11, 21, 31])
|
|
np.testing.assert_array_equal(y_data, [1, 2, 3])
|
|
|
|
# One timestamp for a multi-sample fragment: length mismatch -> index
|
|
source = _async_source("async_device", values=([1, 2, 3],), timestamps=(11,), update_type="add")
|
|
wf._on_data_update(_make_update([source]))
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [0, 1, 2])
|
|
np.testing.assert_array_equal(y_data, [1, 2, 3])
|
|
|
|
|
|
def test_on_data_update_async_history_rows(qtbot, mocked_client, monkeypatch):
|
|
"""
|
|
History emissions carry no async-update metadata: scalar rows form the
|
|
full series, array rows display the last row (mirrors the legacy 2-D
|
|
behaviour).
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
|
wf.scan_id = "dummy"
|
|
wf.x_axis_mode["name"] = "index"
|
|
|
|
# Scalar rows (1-D dataset)
|
|
source = _async_source("async_device", values=(1, 2, 3), update_type=None, max_shape=())
|
|
wf._on_data_update(_make_update([source], reason="history"))
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [0, 1, 2])
|
|
np.testing.assert_array_equal(y_data, [1, 2, 3])
|
|
|
|
# Array rows (2-D dataset): last row is displayed
|
|
source = _async_source(
|
|
"async_device", values=([1, 2, 3], [4, 5, 6]), update_type=None, max_shape=()
|
|
)
|
|
wf._on_data_update(_make_update([source], reason="history"))
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [0, 1, 2])
|
|
np.testing.assert_array_equal(y_data, [4, 5, 6])
|
|
|
|
|
|
def test_on_data_update_async_add_incremental_matches_full_rebuild(
|
|
qtbot, mocked_client, monkeypatch
|
|
):
|
|
"""
|
|
The incremental 1-D 'add' render path must yield exactly the series the
|
|
from-scratch concatenation yields, across live appends (contiguous and
|
|
gapped), an unchanged reused snapshot, an out-of-order hole-fill, a
|
|
non-live reason and a scan change — and must only fall back to the full
|
|
rebuild for the emissions that cannot be pure appends.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
|
wf.scan_id = None # render updates of any scan
|
|
wf.x_axis_mode["name"] = "index"
|
|
|
|
from_scratch = Waveform._async_display_values
|
|
rebuilds = []
|
|
|
|
def counting(source):
|
|
rebuilds.append(source)
|
|
return from_scratch(source)
|
|
|
|
monkeypatch.setattr(Waveform, "_async_display_values", staticmethod(counting))
|
|
|
|
steps = [
|
|
# (scan_id, reason, values, ordinals, expected rebuild count so far)
|
|
("scan_1", "live", ([0.0, 1.0],), (0,), 1), # first emission seeds the cache
|
|
("scan_1", "live", ([0.0, 1.0], [2.0]), (0, 1), 1), # append
|
|
("scan_1", "live", ([0.0, 1.0], [2.0]), (0, 1), 1), # unchanged reused snapshot
|
|
("scan_1", "live", ([0.0, 1.0], [2.0], [4.0, 5.0]), (0, 1, 3), 1), # gapped append
|
|
# late hole-fill below the frontier -> full rebuild
|
|
("scan_1", "live", ([0.0, 1.0], [2.0], [3.0], [4.0, 5.0]), (0, 1, 2, 3), 2),
|
|
("scan_1", "live", ([0.0, 1.0], [2.0], [3.0], [4.0, 5.0], [6.0]), (0, 1, 2, 3, 4), 2),
|
|
# non-live reason -> full rebuild
|
|
("scan_1", "backfill", ([0.0, 1.0], [2.0], [3.0], [4.0, 5.0], [6.0]), tuple(range(5)), 3),
|
|
("scan_2", "live", ([7.0],), (0,), 4), # scan change -> full rebuild
|
|
("scan_2", "live", ([7.0], [8.0, 9.0]), (0, 1), 4), # incremental resumes
|
|
]
|
|
for scan_id, reason, values, ordinals, expected_rebuilds in steps:
|
|
source = _async_source("async_device", values=values, ordinals=ordinals, update_type="add")
|
|
wf._on_data_update(_make_update([source], scan_id=scan_id, reason=reason))
|
|
x_data, y_data = c.get_data()
|
|
expected = from_scratch(source)
|
|
np.testing.assert_array_equal(y_data, expected)
|
|
np.testing.assert_array_equal(x_data, np.arange(len(expected)))
|
|
assert len(rebuilds) == expected_rebuilds
|
|
|
|
|
|
##################################################
|
|
# The following tests are for the Curve class
|
|
##################################################
|
|
|
|
|
|
def test_curve_set_appearance_methods(qtbot, mocked_client):
|
|
"""
|
|
Test that the Curve appearance setter methods update the configuration properly.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="appearance_curve")
|
|
c.set_color("#0000ff")
|
|
c.set_symbol("x")
|
|
c.set_symbol_color("#ff0000")
|
|
c.set_symbol_size(10)
|
|
c.set_pen_width(3)
|
|
c.set_pen_style("dashdot")
|
|
assert c.config.color == "#0000ff"
|
|
assert c.config.symbol == "x"
|
|
assert c.config.symbol_color == "#ff0000"
|
|
assert c.config.symbol_size == 10
|
|
assert c.config.pen_width == 3
|
|
assert c.config.pen_style == "dashdot"
|
|
|
|
|
|
def test_curve_set_custom_data(qtbot, mocked_client):
|
|
"""
|
|
Test that custom curves allow setting new data via set_data.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_data_curve")
|
|
# Change data
|
|
c.set_data([7, 8, 9], [10, 11, 12])
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, np.array([7, 8, 9]))
|
|
np.testing.assert_array_equal(y_data, np.array([10, 11, 12]))
|
|
|
|
|
|
def test_curve_set_data_error_non_custom(qtbot, mocked_client):
|
|
"""
|
|
Test that calling set_data on a non-custom (device) curve raises a ValueError.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
# Create a device curve by providing device_y (which makes source 'device')
|
|
# Assume that entry_validator returns a valid entry.
|
|
c = wf.plot(arg1="bpm4i", label="device_curve")
|
|
with pytest.raises(ValueError):
|
|
c.set_data([1, 2, 3], [4, 5, 6])
|
|
|
|
|
|
def test_curve_remove(qtbot, mocked_client):
|
|
"""
|
|
Test that calling remove() on a Curve calls its parent's remove_curve method.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c1 = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve_1")
|
|
c2 = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve_2")
|
|
|
|
assert len(wf.plot_item.curves) == 2
|
|
c1.remove()
|
|
assert len(wf.plot_item.curves) == 1
|
|
assert c1 not in wf.plot_item.curves
|
|
assert c2 in wf.plot_item.curves
|
|
|
|
|
|
def test_curve_dap_params_and_summary(qtbot, mocked_client):
|
|
"""
|
|
Test that dap_params and dap_summary properties work as expected.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="dap_curve")
|
|
c.dap_params = {"param": 1}
|
|
c.dap_summary = {"summary": "test"}
|
|
assert c.dap_params == {"param": 1}
|
|
assert c.dap_summary == {"summary": "test"}
|
|
|
|
|
|
def test_curve_set_method(qtbot, mocked_client):
|
|
"""
|
|
Test the convenience set(...) method of the Curve for updating appearance properties.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="set_method_curve")
|
|
c.set(
|
|
color="#123456",
|
|
symbol="d",
|
|
symbol_color="#654321",
|
|
symbol_size=12,
|
|
pen_width=5,
|
|
pen_style="dot",
|
|
)
|
|
assert c.config.color == "#123456"
|
|
assert c.config.symbol == "d"
|
|
assert c.config.symbol_color == "#654321"
|
|
assert c.config.symbol_size == 12
|
|
assert c.config.pen_width == 5
|
|
assert c.config.pen_style == "dot"
|
|
|
|
|
|
##################################################
|
|
# Settings and popups
|
|
##################################################
|
|
|
|
|
|
def test_show_curve_settings_popup(qtbot, mocked_client):
|
|
"""
|
|
Test that show_curve_settings_popup displays the settings dialog and toggles the toolbar icon.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
curve_action = wf.toolbar.components.get_action("curve").action
|
|
assert not curve_action.isChecked(), "Should start unchecked"
|
|
|
|
wf.show_curve_settings_popup()
|
|
|
|
assert wf.curve_settings_dialog is not None
|
|
assert wf.curve_settings_dialog.isVisible()
|
|
assert curve_action.isChecked()
|
|
|
|
# add a new row to the curve tree
|
|
add_action = wf.curve_settings_dialog.widget.curve_manager.toolbar.components.get_action("add")
|
|
add_action.action.trigger()
|
|
add_action.action.trigger()
|
|
qtbot.wait(100)
|
|
# Check that the new row is added
|
|
assert wf.curve_settings_dialog.widget.curve_manager.tree.model().rowCount() == 2
|
|
|
|
wf.curve_settings_dialog.close()
|
|
assert wf.curve_settings_dialog is None
|
|
assert not curve_action.isChecked(), "Should be unchecked after closing dialog"
|
|
|
|
|
|
def test_show_dap_summary_popup(qtbot, mocked_client):
|
|
"""
|
|
Test that show_dap_summary_popup displays the DAP summary dialog and toggles the 'fit_params' toolbar icon.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client, popups=True)
|
|
|
|
assert wf.toolbar.components.exists("fit_params")
|
|
|
|
fit_action = wf.toolbar.components.get_action("fit_params").action
|
|
assert fit_action.isChecked() is False
|
|
|
|
wf.show_dap_summary_popup()
|
|
|
|
assert wf.dap_summary_dialog is not None
|
|
assert wf.dap_summary_dialog.isVisible()
|
|
assert fit_action.isChecked() is True
|
|
|
|
wf.dap_summary_dialog.close()
|
|
assert wf.dap_summary_dialog is None
|
|
assert fit_action.isChecked() is False
|
|
|
|
|
|
def test_show_scan_history_popup(qtbot, mocked_client):
|
|
"""
|
|
Test that show_scan_history_popup displays the scan history browser dialog
|
|
and toggles the toolbar action correctly.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
scan_action = wf.toolbar.components.get_action("scan_history").action
|
|
# Initially unchecked and no dialog
|
|
assert not scan_action.isChecked()
|
|
assert wf.scan_history_dialog is None
|
|
|
|
# Show the popup
|
|
wf.show_scan_history_popup()
|
|
# Dialog should exist and be visible, action checked
|
|
assert wf.scan_history_dialog is not None
|
|
assert wf.scan_history_dialog.isVisible()
|
|
assert scan_action.isChecked()
|
|
# The embedded widget should be the correct type
|
|
assert isinstance(wf.scan_history_widget, ScanHistoryBrowser)
|
|
|
|
# Close the dialog (triggers _scan_history_closed)
|
|
wf.scan_history_dialog.close()
|
|
# Dialog reference should be cleared and action unchecked
|
|
assert wf.scan_history_dialog is None
|
|
assert not scan_action.isChecked()
|
|
|
|
|
|
#####################################################
|
|
# The following tests are for the dataset-size guard
|
|
#####################################################
|
|
|
|
|
|
def test_skip_large_dataset_warning_property(qtbot, mocked_client, monkeypatch):
|
|
"""
|
|
Verify the getter and setter of skip_large_dataset_warning work correctly.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
# Default should be False
|
|
assert wf.skip_large_dataset_warning is False
|
|
|
|
# Set to True
|
|
wf.skip_large_dataset_warning = True
|
|
assert wf.skip_large_dataset_warning is True
|
|
|
|
# Toggle back to False
|
|
wf.skip_large_dataset_warning = False
|
|
assert wf.skip_large_dataset_warning is False
|
|
|
|
|
|
def test_skip_large_dataset_check_property(qtbot, mocked_client, monkeypatch):
|
|
"""
|
|
Verify the getter and setter of the per-plot skip_large_dataset_check flag.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
assert wf.skip_large_dataset_check is False
|
|
wf.skip_large_dataset_check = True
|
|
assert wf.skip_large_dataset_check is True
|
|
|
|
|
|
def test_max_dataset_size_mb_property(qtbot, mocked_client, monkeypatch):
|
|
"""
|
|
Verify getter, setter, and validation of max_dataset_size_mb.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
# Default from WaveformConfig is 10 MB
|
|
assert wf.max_dataset_size_mb == 10
|
|
|
|
# Set to a valid new value
|
|
wf.max_dataset_size_mb = 5.5
|
|
assert wf.max_dataset_size_mb == 5.5
|
|
# Ensure the config is updated too
|
|
assert wf.config.max_dataset_size_mb == 5.5
|
|
|
|
# The config survives a round-trip through the widget config dict
|
|
assert wf._config_dict["max_dataset_size_mb"] == 5.5
|
|
|
|
|
|
def _oversized(monkeypatch, wf, size_mb=50.0, shape=(100, 1000)):
|
|
"""Make every source of the widget look oversized, with a known shape."""
|
|
monkeypatch.setattr(
|
|
Waveform, "_estimate_source_bytes", lambda self, scan, source: int(size_mb * 1024 * 1024)
|
|
)
|
|
monkeypatch.setattr(Waveform, "_stored_shape", lambda self, scan, device, entry: shape)
|
|
return wf
|
|
|
|
|
|
def test_small_dataset_loads_without_prompt(qtbot, mocked_client, monkeypatch):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
monkeypatch.setattr(Waveform, "_estimate_source_bytes", lambda self, scan, source: 1024)
|
|
with mock.patch.object(wf, "_confirm_large_dataset") as dialog:
|
|
kept = wf._filter_oversized_sources([("bpm4i", "bpm4i")], "scan-1")
|
|
dialog.assert_not_called()
|
|
assert kept == [("bpm4i", "bpm4i")]
|
|
|
|
|
|
def test_live_and_skip_check_bypass_the_gate(qtbot, mocked_client, monkeypatch):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
_oversized(monkeypatch, wf)
|
|
sources = [("waveform", "waveform_waveform")]
|
|
with mock.patch.object(wf, "_confirm_large_dataset") as dialog:
|
|
assert wf._filter_oversized_sources(sources, "live") == sources
|
|
assert wf._filter_oversized_sources(sources, None) == sources
|
|
wf.skip_large_dataset_check = True
|
|
assert wf._filter_oversized_sources(sources, "scan-1") == sources
|
|
dialog.assert_not_called()
|
|
|
|
|
|
def test_oversized_dataset_prompts_per_dataset_and_loads_on_accept(
|
|
qtbot, mocked_client, monkeypatch
|
|
):
|
|
"""The user decides per dataset; an accepted dataset is loaded and is not
|
|
re-prompted when the subscription is rebuilt."""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
_oversized(monkeypatch, wf, size_mb=50.0, shape=(100, 1000))
|
|
sources = [("waveform", "waveform_waveform"), ("bpm4i", "bpm4i")]
|
|
|
|
with mock.patch.object(wf, "_confirm_large_dataset", return_value=True) as dialog:
|
|
kept = wf._filter_oversized_sources(sources, "scan-1")
|
|
assert kept == sources
|
|
# One prompt per dataset, carrying the dataset identity and its shape.
|
|
assert dialog.call_count == 2
|
|
first = dialog.call_args_list[0]
|
|
assert first.kwargs["source"] == ("waveform", "waveform_waveform")
|
|
assert first.kwargs["shape"] == (100, 1000)
|
|
assert first.args[0] == pytest.approx(50.0)
|
|
|
|
with mock.patch.object(wf, "_confirm_large_dataset") as dialog2:
|
|
assert wf._filter_oversized_sources(sources, "scan-1") == sources
|
|
dialog2.assert_not_called()
|
|
|
|
|
|
def test_declined_dataset_is_not_loaded_but_others_are(qtbot, mocked_client, monkeypatch):
|
|
"""Declining drops only that dataset — the curve stays (empty), it is not
|
|
hidden, and the remaining datasets still load."""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
def estimate(self, scan, source):
|
|
return 50 * 1024 * 1024 if source[0] == "waveform" else 1024
|
|
|
|
monkeypatch.setattr(Waveform, "_estimate_source_bytes", estimate)
|
|
monkeypatch.setattr(Waveform, "_stored_shape", lambda self, scan, device, entry: (100, 1000))
|
|
sources = [("waveform", "waveform_waveform"), ("bpm4i", "bpm4i")]
|
|
|
|
with mock.patch.object(wf, "_confirm_large_dataset", return_value=False) as dialog:
|
|
kept = wf._filter_oversized_sources(sources, "scan-1")
|
|
dialog.assert_called_once()
|
|
assert kept == [("bpm4i", "bpm4i")]
|
|
|
|
|
|
def test_skip_large_dataset_warning_suppresses_dialog(qtbot, mocked_client, monkeypatch):
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
_oversized(monkeypatch, wf)
|
|
wf.skip_large_dataset_warning = True
|
|
with mock.patch.object(wf, "_confirm_large_dataset") as dialog:
|
|
kept = wf._filter_oversized_sources([("waveform", "waveform_waveform")], "scan-1")
|
|
dialog.assert_not_called()
|
|
assert kept == []
|
|
|
|
|
|
def test_history_subscription_gates_before_reading(qtbot, mocked_client, monkeypatch):
|
|
"""No bridge (and therefore no file read) is created for a declined
|
|
dataset."""
|
|
bridges = _fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.plot("bpm4i") # a configured curve, so the widget has sources to bind
|
|
_oversized(monkeypatch, wf)
|
|
bridges.clear()
|
|
|
|
with mock.patch.object(wf, "_confirm_large_dataset", return_value=False):
|
|
wf._setup_data_api_subscription(scan="scan-1")
|
|
assert not bridges
|
|
assert wf._data_bridge is None
|
|
|
|
with mock.patch.object(wf, "_confirm_large_dataset", return_value=True):
|
|
wf._setup_data_api_subscription(scan="scan-1")
|
|
assert bridges and bridges[-1].scan == "scan-1"
|
|
# The gate is decided up front, so the bridge itself is never size-gated.
|
|
assert getattr(bridges[-1], "size_limit_bytes", None) is None
|
|
|
|
|
|
def test_describe_dataset_reports_point_counts():
|
|
assert "1,000 points" in Waveform._describe_dataset(("det", "sig"), (1000,))
|
|
text = Waveform._describe_dataset(("det", "sig"), (100, 1000))
|
|
assert "100 points x 1,000 samples" in text
|
|
assert "'det-sig'" in text
|
|
|
|
|
|
def _open_dialog_and_click(handler):
|
|
"""
|
|
Utility that schedules *handler* to run as soon as a modal
|
|
dialog is shown. Returns a function suitable for QTimer.singleShot.
|
|
"""
|
|
|
|
def _cb():
|
|
# Locate the active modal dialog
|
|
dlg = QApplication.activeModalWidget()
|
|
assert isinstance(dlg, QDialog), "No active modal dialog found"
|
|
handler(dlg)
|
|
|
|
return _cb
|
|
|
|
|
|
def test_dialog_accept_real_interaction(qtbot, mocked_client, monkeypatch):
|
|
"""
|
|
End-to-end: user changes the limit spinner to 5 MiB, ticks
|
|
'don't show again', then presses YES.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.max_dataset_size_mb = 1
|
|
|
|
def handler(dlg):
|
|
spin: QDoubleSpinBox = dlg.findChild(QDoubleSpinBox)
|
|
chk: QCheckBox = dlg.findChild(QCheckBox)
|
|
btns: QDialogButtonBox = dlg.findChild(QDialogButtonBox)
|
|
|
|
spin.setValue(5)
|
|
chk.setChecked(True)
|
|
|
|
yes_btn = btns.button(QDialogButtonBox.Yes)
|
|
yes_btn.click()
|
|
|
|
QTimer.singleShot(0, _open_dialog_and_click(handler))
|
|
|
|
accepted = wf._confirm_large_dataset(4.6)
|
|
assert accepted is True
|
|
assert wf.max_dataset_size_mb == 5
|
|
assert wf.skip_large_dataset_warning is True
|
|
|
|
|
|
def test_dialog_reject_real_interaction(qtbot, mocked_client, monkeypatch):
|
|
"""
|
|
End-to-end: user leaves spinner unchanged, ticks 'don't show again',
|
|
and presses NO.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.max_dataset_size_mb = 1
|
|
|
|
def handler(dlg):
|
|
chk: QCheckBox = dlg.findChild(QCheckBox)
|
|
btns: QDialogButtonBox = dlg.findChild(QDialogButtonBox)
|
|
|
|
chk.setChecked(True)
|
|
no_btn = btns.button(QDialogButtonBox.No)
|
|
no_btn.click()
|
|
|
|
QTimer.singleShot(0, _open_dialog_and_click(handler))
|
|
|
|
accepted = wf._confirm_large_dataset(4.6)
|
|
assert accepted is False
|
|
assert wf.skip_large_dataset_warning is True
|
|
# Limit remains unchanged
|
|
assert wf.max_dataset_size_mb == 1
|
|
|
|
|
|
def test_update_with_scan_history_by_index(qtbot, mocked_client, scan_history_factory, monkeypatch):
|
|
"""
|
|
Test that update_with_scan_history by index loads the correct historical scan.
|
|
"""
|
|
bridges = _fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
hist1, hist2 = inject_scan_history(wf, scan_history_factory, ("hist1", 1), ("hist2", 2))
|
|
|
|
assert len(wf.client.history._scan_ids) == 2, "Expected two history scans"
|
|
|
|
# Do history curve plotting
|
|
wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id="hist1")
|
|
wf.plot(device_y="bpm4i", scan_number=2)
|
|
|
|
assert len(wf.plot_item.curves) == 2, "Expected two curves for history scans"
|
|
c1, c2 = wf.plot_item.curves
|
|
# First curve should be for hist1, second for hist2
|
|
assert c1.config.signal.device == "bpm4i"
|
|
assert c1.config.signal.signal == "bpm4i"
|
|
assert c1.config.scan_id == "hist1"
|
|
assert c1.config.scan_number == 1
|
|
assert c1.name() == "bpm4i-bpm4i-scan-1"
|
|
|
|
assert c2.config.signal.device == "bpm4i"
|
|
assert c2.config.signal.signal == "bpm4i"
|
|
assert c2.config.scan_id == "hist2"
|
|
assert c2.config.scan_number == 2
|
|
assert c2.name() == "bpm4i-bpm4i-scan-2"
|
|
|
|
# One scan-bound subscription per pinned history scan.
|
|
history_scans = {bridge.scan for bridge in bridges if not bridge.closed}
|
|
assert history_scans == {"hist1", "hist2"}
|
|
|
|
|
|
def test_history_curve_receives_data_from_scan_bound_subscription(
|
|
qtbot, mocked_client, scan_history_factory, monkeypatch
|
|
):
|
|
"""
|
|
History curve data flows through a DataAPI subscription bound to the
|
|
pinned scan id; updates are routed by scan id and rendered against the
|
|
auto-resolved x device.
|
|
"""
|
|
bridges = _fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
inject_scan_history(wf, scan_history_factory, ("hist1", 1))
|
|
|
|
c = wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id="hist1")
|
|
history_bridge = wf._history_bridges["hist1"]
|
|
assert history_bridge in bridges
|
|
assert history_bridge.scan == "hist1"
|
|
assert ("bpm4i", "bpm4i") in history_bridge.sources
|
|
# Auto mode pulls in the scan's first report device as x source.
|
|
assert ("samx", "samx") in history_bridge.sources
|
|
|
|
update = _make_update(
|
|
[
|
|
_monitored_source("bpm4i", values=(5, 6, 7)),
|
|
_monitored_source("samx", values=(10, 20, 30)),
|
|
],
|
|
scan_id="hist1",
|
|
reason="history",
|
|
)
|
|
wf._on_data_update(update)
|
|
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(x_data, [10, 20, 30])
|
|
np.testing.assert_array_equal(y_data, [5, 6, 7])
|
|
|
|
# Updates for other scans do not touch the pinned curve.
|
|
other = _make_update([_monitored_source("bpm4i", values=(1, 1, 1))], scan_id="other-scan")
|
|
wf._on_data_update(other)
|
|
_, y_data = c.get_data()
|
|
np.testing.assert_array_equal(y_data, [5, 6, 7])
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["auto", "timestamp", "index", "samx"])
|
|
def test_history_curve_x_modes_pre_plot(
|
|
qtbot, mocked_client, scan_history_factory, mode, monkeypatch
|
|
):
|
|
"""
|
|
Test that history curves respect x_mode when set before plotting.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
hist1, hist2 = inject_scan_history(wf, scan_history_factory, ("hist1", 1), ("hist2", 2))
|
|
wf.x_mode = mode
|
|
c = wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id="hist1")
|
|
assert c.config.current_x_mode == mode
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["auto", "timestamp", "index", "samx"])
|
|
def test_history_curve_x_modes_post_plot(
|
|
qtbot, mocked_client, scan_history_factory, mode, monkeypatch
|
|
):
|
|
"""
|
|
Test that changing x_mode after plotting history curves updates the curve on refresh.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
hist1, hist2 = inject_scan_history(wf, scan_history_factory, ("hist1", 1), ("hist2", 2))
|
|
c = wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id="hist1")
|
|
# Change x_mode after plotting
|
|
wf.x_mode = mode
|
|
# Refresh history curves
|
|
wf._refresh_history_curves()
|
|
assert c.config.current_x_mode == mode
|
|
|
|
|
|
def test_history_curve_incompatible_x_mode_hides_curve(
|
|
qtbot, mocked_client, scan_history_factory, monkeypatch
|
|
):
|
|
"""
|
|
Test that setting an x_mode not present in stored data hides the history curve.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.x_mode = "nonexistent_device"
|
|
# Inject history scan for this test
|
|
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_bad", 1))
|
|
# Plot history curve
|
|
c = wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id=history_msg.scan_id)
|
|
# Curve should be hidden due to incompatible x_mode
|
|
assert not c.isVisible()
|
|
|
|
|
|
def test_history_curve_no_stored_data_raises(
|
|
qtbot, mocked_client, monkeypatch, suppress_message_box
|
|
):
|
|
"""
|
|
Test that plotting a history curve when stored_data_info is missing
|
|
raises ValueError (metadata-level validation kept from the legacy fetch).
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
# Create a dummy scan_item lacking stored_data_info
|
|
dummy_scan = SimpleNamespace(
|
|
_msg=SimpleNamespace(stored_data_info=None),
|
|
devices={},
|
|
metadata={"bec": {"scan_id": "dummy", "scan_number": 1, "scan_report_devices": []}},
|
|
)
|
|
# Force get_history_scan_item to return our dummy
|
|
monkeypatch.setattr(wf, "get_history_scan_item", lambda scan_id, scan_index: dummy_scan)
|
|
# Attempt to plot history curve should be suppressed by SafeSlot and return None
|
|
c = wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id="dummy", scan_number=1)
|
|
assert c is None
|
|
assert len(wf.curves) == 0
|
|
|
|
|
|
def test_history_curve_device_missing_returns_none(qtbot, mocked_client, scan_history_factory):
|
|
"""
|
|
If the y-device is not in stored_data_info, plot should return None.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.x_mode = "index"
|
|
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_dev_missing", 1))
|
|
c = wf.plot(device_y="non-existing", signal_y="non-existing", scan_id=history_msg.scan_id)
|
|
assert c is None
|
|
|
|
|
|
def test_history_curve_custom_shape_mismatch_hides_curve(
|
|
qtbot, mocked_client, scan_history_factory, monkeypatch
|
|
):
|
|
"""
|
|
For custom x-mode, if x and y shapes mismatch, curve should be hidden.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.x_mode = "async_device"
|
|
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_custom_shape", 1))
|
|
# Force shape mismatch for x-data
|
|
c = wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id=history_msg.scan_id)
|
|
assert c is not None
|
|
assert not c.isVisible()
|
|
|
|
|
|
def test_history_curve_index_mode_plots_curve(
|
|
qtbot, mocked_client, scan_history_factory, monkeypatch
|
|
):
|
|
"""
|
|
Test that setting x_mode to 'index' plots and shows the history curve correctly.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.x_mode = "index"
|
|
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_index", 1))
|
|
c = wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id=history_msg.scan_id)
|
|
assert c is not None
|
|
assert c.isVisible()
|
|
assert c.config.current_x_mode == "index"
|
|
|
|
|
|
def test_history_curve_timestamp_mode_plots_curve(
|
|
qtbot, mocked_client, scan_history_factory, monkeypatch
|
|
):
|
|
"""
|
|
Test that setting x_mode to 'timestamp' plots and shows the history curve correctly.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.x_mode = "timestamp"
|
|
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_time", 1))
|
|
c = wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id=history_msg.scan_id)
|
|
assert c is not None
|
|
assert c.isVisible()
|
|
assert c.config.current_x_mode == "timestamp"
|
|
|
|
|
|
def test_history_curve_auto_valid_uses_first_report_device(
|
|
qtbot, mocked_client, scan_history_factory, monkeypatch
|
|
):
|
|
"""
|
|
Test that 'auto' x_mode uses the first available report device and shows the curve.
|
|
"""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.x_mode = "auto"
|
|
[history_msg] = inject_scan_history(wf, scan_history_factory, ("hist_auto_valid", 1))
|
|
# Plot history curve
|
|
c = wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id=history_msg.scan_id)
|
|
assert c is not None
|
|
assert c.isVisible()
|
|
# Should have fallen back to the first scan_report_device
|
|
assert c.config.current_x_mode == "auto"
|
|
|
|
|
|
def test_history_curve_file_not_found_returns_none(qtbot, mocked_client, scan_history_factory):
|
|
"""
|
|
If the history file path does not exist, plot should return None.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.x_mode = "index"
|
|
# Inject a valid history message then corrupt its file_path
|
|
[history_msg] = inject_scan_history(wf, scan_history_factory, ("bad_file", 1))
|
|
history_msg.file_path = "/nonexistent/path.h5"
|
|
c = wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id=history_msg.scan_id)
|
|
assert c is None
|
|
|
|
|
|
def test_history_curve_scan_not_found_returns_none(qtbot, mocked_client):
|
|
"""
|
|
If the requested scan_id is not in history, plot should return None.
|
|
"""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.x_mode = "index"
|
|
# No history scans injected for this widget
|
|
c = wf.plot(device_y="bpm4i", signal_y="bpm4i", scan_id="unknown_scan")
|
|
assert c is None
|
|
|
|
|
|
def test_categorise_device_curves_monitored_device_with_async_signal(qtbot, mocked_client):
|
|
"""Device listed under 'monitored'
|
|
readout priority may expose an asynchronous signal; the curve must be
|
|
classified by the signal class, not the parent device's readout priority."""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
dummy_scan = create_dummy_scan_item()
|
|
wf.scan_item = dummy_scan
|
|
|
|
# bpm4i is listed under 'monitored' in the dummy scan; give it an
|
|
# additional async signal, as a mixed device would have.
|
|
device = mocked_client.device_manager.devices["bpm4i"]
|
|
device.signals["bpm4i_stream"] = {"value": 0.0}
|
|
device._info["signals"]["bpm4i_stream"] = {
|
|
"kind_str": "hinted",
|
|
"component_name": "bpm4i_stream",
|
|
"obj_name": "bpm4i_stream",
|
|
"signal_class": "AsyncSignal",
|
|
}
|
|
|
|
c_sync = wf.plot(arg1="bpm4i", label="sync-curve")
|
|
c_async = wf.plot(device_y="bpm4i", signal_y="bpm4i_stream", label="async-curve")
|
|
|
|
mode = wf._categorise_device_curves()
|
|
|
|
assert mode == "mixed"
|
|
assert c_sync in wf._sync_curves
|
|
assert c_async in wf._async_curves
|
|
|
|
|
|
def test_categorise_device_curves_falls_back_to_readout_priority(qtbot, mocked_client):
|
|
"""When no signal info is available (e.g. history data for a removed
|
|
device), classification falls back to the scan's readout-priority lists."""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
dummy_scan = create_dummy_scan_item()
|
|
wf.scan_item = dummy_scan
|
|
|
|
c_async = wf.plot(arg1="async_device", label="fallback-curve")
|
|
# Simulate the device's signal info being unavailable.
|
|
mocked_client.device_manager.devices["async_device"]._info = {}
|
|
|
|
wf._categorise_device_curves()
|
|
|
|
assert c_async in wf._async_curves
|
|
|
|
|
|
def test_x_source_resolution_does_not_open_the_data_file(qtbot, mocked_client, monkeypatch):
|
|
"""Auto x-mode must resolve the report device from in-memory metadata:
|
|
ScanDataContainer.metadata lazily opens the HDF5 file, which would block
|
|
the GUI thread (and would even precede the large-dataset gate)."""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
|
|
class _ExplodingMetadata:
|
|
def __getitem__(self, key):
|
|
raise AssertionError("data file was opened on the GUI thread")
|
|
|
|
def get(self, *args, **kwargs):
|
|
raise AssertionError("data file was opened on the GUI thread")
|
|
|
|
# History container: report device comes from the scan history message.
|
|
history_item = SimpleNamespace(
|
|
metadata=_ExplodingMetadata(),
|
|
_msg=SimpleNamespace(request_inputs={"arg_bundle": ["samx", -5, 5, 10], "kwargs": {}}),
|
|
status_message=None,
|
|
)
|
|
assert wf._report_devices_no_file_io(history_item) == ["samx"]
|
|
assert wf._history_x_source_key(history_item) == ("samx", "samx")
|
|
|
|
# Live scan item: report devices come from the status message.
|
|
live_item = SimpleNamespace(
|
|
metadata=_ExplodingMetadata(),
|
|
status_message=SimpleNamespace(scan_report_devices=["samy"], info={}),
|
|
_msg=None,
|
|
)
|
|
assert wf._report_devices_no_file_io(live_item) == ["samy"]
|
|
|
|
|
|
def test_x_source_falls_back_to_file_metadata_when_unavailable(qtbot, mocked_client):
|
|
"""When neither the status message nor the history message names the
|
|
report devices, the file metadata is still consulted (last resort)."""
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
scan_item = SimpleNamespace(
|
|
metadata={"bec": {"scan_report_devices": ["samx"]}}, status_message=None, _msg=None
|
|
)
|
|
assert wf._report_devices_no_file_io(scan_item) == []
|
|
assert wf._history_x_source_key(scan_item) == ("samx", "samx")
|
|
|
|
|
|
def test_detector_shaped_history_curve_is_not_hidden(qtbot, mocked_client, monkeypatch):
|
|
"""A large detector dataset has more rows than the scan has points; it is
|
|
plotted against the sample index, so the x/y row-count difference must not
|
|
hide the curve (regression: large history datasets disappeared)."""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.x_mode = "samx" # custom device x-axis: the strictest visibility path
|
|
|
|
stored = {
|
|
"samx": {"samx": SimpleNamespace(shape=(100,))},
|
|
# Flat async concatenation: 100 acquisitions x 1000 samples appended
|
|
# along one axis — the shape a large "add" async signal really has.
|
|
"waveform": {"waveform_waveform": SimpleNamespace(shape=(100_000,))},
|
|
# 2-D detector data (one row per acquisition).
|
|
"eiger": {"eiger_data": SimpleNamespace(shape=(100, 1000))},
|
|
"bpm4i": {"bpm4i": SimpleNamespace(shape=(100,))},
|
|
}
|
|
scan_item = SimpleNamespace(
|
|
_msg=SimpleNamespace(
|
|
stored_data_info=stored,
|
|
num_points=100,
|
|
num_monitored_readouts=100,
|
|
request_inputs={"arg_bundle": ["samx", -5, 5, 100], "kwargs": {}},
|
|
),
|
|
status_message=None,
|
|
)
|
|
monkeypatch.setattr(Waveform, "get_history_scan_item", lambda self, **kwargs: scan_item)
|
|
|
|
def curve_for(device, signal):
|
|
return SimpleNamespace(
|
|
config=SimpleNamespace(
|
|
scan_id="scan-1",
|
|
scan_number=None,
|
|
signal=SimpleNamespace(device=device, signal=signal),
|
|
),
|
|
name=lambda: f"{device}-{signal}",
|
|
)
|
|
|
|
# Flat async data (100 000 samples vs a 100-point motor): visible.
|
|
assert wf._history_curve_compatible(curve_for("waveform", "waveform_waveform")) is True
|
|
# 2-D detector data: visible.
|
|
assert wf._history_curve_compatible(curve_for("eiger", "eiger_data")) is True
|
|
# A monitored signal with the same length as x: visible.
|
|
assert wf._history_curve_compatible(curve_for("bpm4i", "bpm4i")) is True
|
|
# A monitored signal whose length disagrees with x is still hidden
|
|
# (unchanged behaviour for point-per-scan-point data).
|
|
stored["bpm4i"]["bpm4i"] = SimpleNamespace(shape=(37,))
|
|
scan_item._msg.num_monitored_readouts = 37
|
|
scan_item._msg.num_points = 37
|
|
assert wf._history_curve_compatible(curve_for("bpm4i", "bpm4i")) is False
|
|
|
|
|
|
def test_history_source_with_numpy_columns_renders(qtbot, mocked_client, monkeypatch):
|
|
"""Regression: the history plugin delivers numpy-array columns (bulk
|
|
ingest keeps the file arrays intact). The render must accept them; a
|
|
``if not source.values`` truth-test raised ValueError on multi-element
|
|
arrays and SafeSlot swallowed it, so the curve showed no data."""
|
|
_fake_bridge_factory(monkeypatch)
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
wf.x_axis_mode["name"] = "index"
|
|
|
|
# monitored history column, numpy-valued (what bec_lib now emits)
|
|
c = wf.plot(arg1="bpm4i", label="bpm4i-bpm4i")
|
|
wf.scan_id = "dummy"
|
|
src = _monitored_source("bpm4i", values=[5.0, 6.0, 7.0, 8.0], as_numpy=True)
|
|
wf._on_data_update(_make_update([src], reason="history"))
|
|
x_data, y_data = c.get_data()
|
|
np.testing.assert_array_equal(y_data, [5.0, 6.0, 7.0, 8.0])
|
|
|
|
# async history waveform, flat numpy value column, no async_update_type
|
|
ca = wf.plot(arg1="async_device", label="async_device-async_device")
|
|
src_a = _async_source(
|
|
"async_device", values=[1.0, 2.0, 3.0, 4.0, 5.0], update_type=None, as_numpy=True
|
|
)
|
|
# history reads carry no async_update_type
|
|
src_a.metadata.pop("async_update_type", None)
|
|
wf._on_data_update(_make_update([src_a], reason="history"))
|
|
x_data, y_data = ca.get_data()
|
|
assert y_data is not None and len(y_data) == 5
|
|
|
|
|
|
def test_async_display_values_accepts_numpy(qtbot, mocked_client):
|
|
"""Both display helpers must handle numpy-valued sources (bulk history)."""
|
|
from types import SimpleNamespace
|
|
|
|
src = _async_source("async_device", values=[1, 2, 3], update_type="add", as_numpy=True)
|
|
assert list(Waveform._async_display_values(src)) == [1, 2, 3]
|
|
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
|
carrier = SimpleNamespace(_data_api_async_cache=None)
|
|
cached = wf._async_display_values_cached(carrier, _make_update([src]), src)
|
|
assert list(cached) == [1, 2, 3]
|