mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-09-07 08:52:38 +02:00
refactor(image): consume the data api for async and preview streams
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.data_api.models import SourceData, SubscriptionUpdate
|
||||
from qtpy.QtCore import QPointF, Qt
|
||||
|
||||
from bec_widgets.widgets.plots.image.bec_histogram_lut_item import (
|
||||
@@ -30,6 +32,75 @@ def _set_signal_config(
|
||||
}
|
||||
|
||||
|
||||
def _fake_bridge_factory(monkeypatch):
|
||||
"""Replace the widget's QtDataSubscription with a recording fake."""
|
||||
created = []
|
||||
|
||||
class _FakeBridge:
|
||||
def __init__(
|
||||
self, client, sources, scan="live", parent=None, min_emit_interval=0.1, max_points=None
|
||||
):
|
||||
self.client = client
|
||||
self.sources = list(sources)
|
||||
self.scan = scan
|
||||
self.max_points = max_points
|
||||
self.healthy = True
|
||||
self.closed = False
|
||||
self.updated = MagicMock()
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def factory(client, sources, scan="live", parent=None, min_emit_interval=0.1, max_points=None):
|
||||
bridge = _FakeBridge(client, sources, scan=scan, max_points=max_points)
|
||||
created.append(bridge)
|
||||
return bridge
|
||||
|
||||
monkeypatch.setattr("bec_widgets.widgets.plots.image.image.QtDataSubscription", factory)
|
||||
return created
|
||||
|
||||
|
||||
def _make_source(
|
||||
device, entry, values, *, kind="async", ordinals=None, metadata=None, as_numpy=False
|
||||
):
|
||||
values = np.asarray(values) if as_numpy else tuple(values)
|
||||
if ordinals is None:
|
||||
ordinals = tuple(range(len(values)))
|
||||
wrap = np.asarray if as_numpy else tuple
|
||||
return SourceData(
|
||||
device=device,
|
||||
entry=entry,
|
||||
kind=kind,
|
||||
ordinals=wrap(ordinals),
|
||||
values=values,
|
||||
timestamps=wrap([float(i) for i in range(len(values))]),
|
||||
complete=True,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
def _make_update(source, scan_id="scan_1", reason="live"):
|
||||
return SubscriptionUpdate(
|
||||
scan_id=scan_id,
|
||||
reason=reason,
|
||||
sources={source.key: source},
|
||||
aligned_ordinals=source.ordinals,
|
||||
complete=True,
|
||||
metadata={"group": "standalone"},
|
||||
)
|
||||
|
||||
|
||||
def _preview_update(device, entry, values, *, ordinals=None, scan_id=None):
|
||||
"""Build an update as delivered by a scan-less device_preview stream."""
|
||||
metadata = {"stream": "preview"}
|
||||
if scan_id is not None:
|
||||
metadata["scan_id"] = scan_id
|
||||
source = _make_source(
|
||||
device, entry, values, kind="unindexed", ordinals=ordinals, metadata=metadata
|
||||
)
|
||||
return _make_update(source, scan_id="")
|
||||
|
||||
|
||||
class _FakeClickEvent:
|
||||
"""Minimal stand-in for a pyqtgraph MouseClickEvent."""
|
||||
|
||||
@@ -294,13 +365,12 @@ def test_enable_colorbar_with_vrange(qtbot, mocked_client, colorbar_type):
|
||||
# Device/signal update mechanism
|
||||
|
||||
|
||||
def test_image_setup_preview_signal_1d(qtbot, mocked_client):
|
||||
def test_image_setup_preview_signal_1d(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Ensure that calling .image() with a 1‑D PreviewSignal connects using the 1‑D path
|
||||
and updates correctly.
|
||||
Ensure that calling .image() with a 1‑D PreviewSignal connects through a
|
||||
scan-less DataAPI bridge and accumulates rows into the waterfall buffer.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
_set_signal_config(
|
||||
@@ -321,20 +391,25 @@ def test_image_setup_preview_signal_1d(qtbot, mocked_client):
|
||||
assert view.device == "waveform1d"
|
||||
assert view.signal == "img"
|
||||
|
||||
# Simulate a waveform update from the dispatcher
|
||||
# Preview signals are scan-less device streams with bounded retention
|
||||
bridge = bridges[-1]
|
||||
assert bridge.scan is None
|
||||
assert bridge.sources == [("waveform1d", "img")]
|
||||
assert bridge.max_points == Image.PREVIEW_1D_MAX_ROWS
|
||||
|
||||
# Simulate a waveform update from the DataAPI
|
||||
waveform = np.arange(25, dtype=float)
|
||||
view.on_image_update_1d({"data": waveform}, {"scan_id": "scan_test"})
|
||||
view._on_data_update(_preview_update("waveform1d", "img", [waveform]))
|
||||
assert view.main_image.raw_data.shape == (1, 25)
|
||||
np.testing.assert_array_equal(view.main_image.raw_data[0], waveform)
|
||||
|
||||
|
||||
def test_image_setup_preview_signal_2d(qtbot, mocked_client):
|
||||
def test_image_setup_preview_signal_2d(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Ensure that calling .image() with a 2‑D PreviewSignal connects using the 2‑D path
|
||||
and updates correctly.
|
||||
Ensure that calling .image() with a 2‑D PreviewSignal connects through a
|
||||
scan-less DataAPI bridge and displays the newest frame.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
_set_signal_config(
|
||||
@@ -355,46 +430,40 @@ def test_image_setup_preview_signal_2d(qtbot, mocked_client):
|
||||
assert view.device == "eiger"
|
||||
assert view.signal == "img2d"
|
||||
|
||||
# Simulate a 2‑D image update
|
||||
bridge = bridges[-1]
|
||||
assert bridge.scan is None
|
||||
assert bridge.sources == [("eiger", "img2d")]
|
||||
assert bridge.max_points == Image.PREVIEW_2D_MAX_FRAMES
|
||||
|
||||
# Only the newest frame of the stream is displayed
|
||||
test_data = np.arange(16, dtype=float).reshape(4, 4)
|
||||
view.on_image_update_2d({"data": test_data}, {})
|
||||
view._on_data_update(_preview_update("eiger", "img2d", [np.zeros((4, 4)), test_data]))
|
||||
np.testing.assert_array_equal(view.main_image.image, test_data)
|
||||
|
||||
|
||||
def test_switching_device_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
def test_switching_device_replaces_preview_bridge(qtbot, mocked_client, monkeypatch):
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
|
||||
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=2)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
view.image(device="eiger", signal="img")
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
first = bridges[-1]
|
||||
assert first.sources == [("eiger", "img")]
|
||||
|
||||
view.device = "waveform1d"
|
||||
|
||||
assert MessageEndpoints.device_preview("eiger", "img") in disconnected
|
||||
assert MessageEndpoints.device_preview("waveform1d", "img") in connected
|
||||
assert first.closed is True
|
||||
assert bridges[-1].sources == [("waveform1d", "img")]
|
||||
assert bridges[-1].scan is None
|
||||
|
||||
|
||||
def test_switching_device_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
def test_switching_device_replaces_async_bridge(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Verify that switching device while async_update=True disconnects device_async_signal
|
||||
endpoints for both scan_id and old_scan_id on the old device before reconnecting to
|
||||
the new device.
|
||||
Verify that switching device while async_update=True closes the previous
|
||||
scan-scoped bridge before subscribing to the new device.
|
||||
"""
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
|
||||
@@ -403,77 +472,44 @@ def test_switching_device_disconnects_previous_async_endpoint(qtbot, mocked_clie
|
||||
mocked_client, "waveform1d", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
|
||||
)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
view.image(device="eiger", signal="img")
|
||||
assert view.async_update is True
|
||||
assert view.subscriptions["main"].async_signal_name == "async_obj"
|
||||
|
||||
view.scan_id = "scan_current"
|
||||
view.old_scan_id = "scan_previous"
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
first = bridges[-1]
|
||||
assert first.scan == "live"
|
||||
assert first.sources == [("eiger", "async_obj")]
|
||||
|
||||
view.device = "waveform1d"
|
||||
|
||||
# Both scan_id and old_scan_id endpoints for the old device must be disconnected
|
||||
assert (
|
||||
MessageEndpoints.device_async_signal("scan_current", "eiger", "async_obj") in disconnected
|
||||
)
|
||||
assert (
|
||||
MessageEndpoints.device_async_signal("scan_previous", "eiger", "async_obj") in disconnected
|
||||
)
|
||||
# The new device's async endpoint for the current scan must be connected
|
||||
assert (
|
||||
MessageEndpoints.device_async_signal("scan_current", "waveform1d", "async_obj") in connected
|
||||
)
|
||||
assert first.closed is True
|
||||
assert bridges[-1].scan == "live"
|
||||
assert bridges[-1].sources == [("waveform1d", "async_obj")]
|
||||
|
||||
|
||||
def test_switching_signal_disconnects_previous_preview_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
def test_switching_signal_replaces_preview_bridge(qtbot, mocked_client, monkeypatch):
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(mocked_client, "eiger", "img_a", signal_class="PreviewSignal", ndim=2)
|
||||
_set_signal_config(mocked_client, "eiger", "img_b", signal_class="PreviewSignal", ndim=2)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
view.image(device="eiger", signal="img_a")
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
first = bridges[-1]
|
||||
assert first.sources == [("eiger", "img_a")]
|
||||
|
||||
view.signal = "img_b"
|
||||
|
||||
assert MessageEndpoints.device_preview("eiger", "img_a") in disconnected
|
||||
assert MessageEndpoints.device_preview("eiger", "img_b") in connected
|
||||
assert first.closed is True
|
||||
assert bridges[-1].sources == [("eiger", "img_b")]
|
||||
assert bridges[-1].scan is None
|
||||
|
||||
|
||||
def test_switching_signal_disconnects_previous_async_endpoint(qtbot, mocked_client, monkeypatch):
|
||||
def test_switching_signal_replaces_async_bridge(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
When the current monitor is an async signal, switching to a different signal must
|
||||
disconnect the previous async endpoint (based on scan_id/async_signal_name) before
|
||||
reconnecting with the new signal's async endpoint.
|
||||
When the current monitor is an async signal, switching to a different
|
||||
signal must close the previous bridge and subscribe with the new signal's
|
||||
obj_name as the DataAPI entry.
|
||||
"""
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img_a", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_a"
|
||||
@@ -482,40 +518,20 @@ def test_switching_signal_disconnects_previous_async_endpoint(qtbot, mocked_clie
|
||||
mocked_client, "eiger", "img_b", signal_class="AsyncSignal", ndim=2, obj_name="async_obj_b"
|
||||
)
|
||||
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: connected.append(endpoint),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint, *args, **kwargs: disconnected.append(endpoint),
|
||||
)
|
||||
|
||||
# Connect to img_a as an async signal; scan_id is None so no actual subscription is made
|
||||
view.image(device="eiger", signal="img_a")
|
||||
assert view.async_update is True
|
||||
assert view.subscriptions["main"].async_signal_name == "async_obj_a"
|
||||
assert view.subscriptions["main"].source == "device_monitor_2d"
|
||||
|
||||
# Simulate an active scan so that the async endpoint is real
|
||||
view.scan_id = "scan_123"
|
||||
connected.clear()
|
||||
disconnected.clear()
|
||||
first = bridges[-1]
|
||||
assert first.sources == [("eiger", "async_obj_a")]
|
||||
|
||||
# Switch to a different signal
|
||||
view.signal = "img_b"
|
||||
|
||||
# The previous async endpoint for img_a must have been disconnected
|
||||
expected_disconnect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_a")
|
||||
assert expected_disconnect in disconnected
|
||||
|
||||
# The new async endpoint for img_b must have been connected
|
||||
expected_connect = MessageEndpoints.device_async_signal("scan_123", "eiger", "async_obj_b")
|
||||
assert expected_connect in connected
|
||||
assert first.closed is True
|
||||
assert view.subscriptions["main"].async_signal_name == "async_obj_b"
|
||||
assert bridges[-1].sources == [("eiger", "async_obj_b")]
|
||||
assert bridges[-1].scan == "live"
|
||||
|
||||
|
||||
def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch):
|
||||
@@ -568,8 +584,10 @@ def test_preview_signals_skip_0d_entries(qtbot, mocked_client, monkeypatch):
|
||||
|
||||
def test_image_async_signal_uses_obj_name(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Verify async signals use obj_name for endpoints/payloads and reconnect with scan_id.
|
||||
Verify async signals subscribe with their obj_name as the DataAPI entry
|
||||
and render the delivered fragments.
|
||||
"""
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=1, obj_name="async_obj"
|
||||
@@ -579,53 +597,33 @@ def test_image_async_signal_uses_obj_name(qtbot, mocked_client, monkeypatch):
|
||||
assert view.subscriptions["main"].async_signal_name == "async_obj"
|
||||
assert view.async_update is True
|
||||
|
||||
# Prepare scan ids and capture dispatcher calls
|
||||
view.old_scan_id = "old_scan"
|
||||
view.scan_id = "new_scan"
|
||||
connected = []
|
||||
disconnected = []
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"connect_slot",
|
||||
lambda slot, endpoint, from_start=False, cb_info=None: connected.append(
|
||||
(slot, endpoint, from_start, cb_info)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
view.bec_dispatcher,
|
||||
"disconnect_slot",
|
||||
lambda slot, endpoint: disconnected.append((slot, endpoint)),
|
||||
)
|
||||
bridge = bridges[-1]
|
||||
assert bridge.scan == "live"
|
||||
assert bridge.sources == [("eiger", "async_obj")]
|
||||
assert bridge.max_points is None
|
||||
|
||||
view._setup_async_image(view.scan_id)
|
||||
|
||||
expected_new = MessageEndpoints.device_async_signal("new_scan", "eiger", "async_obj")
|
||||
expected_old = MessageEndpoints.device_async_signal("old_scan", "eiger", "async_obj")
|
||||
assert any(ep == expected_new for _, ep, _, _ in connected)
|
||||
assert any(ep == expected_old for _, ep in disconnected)
|
||||
|
||||
# Payload extraction should use obj_name
|
||||
# Rendering reads the source keyed by (device, obj_name)
|
||||
payload = np.array([1, 2, 3])
|
||||
msg = {"signals": {"async_obj": {"value": payload}}}
|
||||
assert np.array_equal(view._get_payload_data(msg), payload)
|
||||
source = _make_source(
|
||||
"eiger", "async_obj", [payload], metadata={"async_update_type": "add", "max_shape": [None]}
|
||||
)
|
||||
view._on_data_update(_make_update(source))
|
||||
np.testing.assert_array_equal(view.main_image.raw_data, payload.reshape(1, 3))
|
||||
|
||||
|
||||
def test_disconnect_clears_async_state(qtbot, mocked_client, monkeypatch):
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
|
||||
)
|
||||
|
||||
view.image(device="eiger", signal="img")
|
||||
view.scan_id = "scan_x"
|
||||
view.old_scan_id = "scan_y"
|
||||
view.subscriptions["main"].async_signal_name = "async_obj"
|
||||
|
||||
# Avoid touching real dispatcher
|
||||
monkeypatch.setattr(view.bec_dispatcher, "disconnect_slot", lambda *args, **kwargs: None)
|
||||
assert view.async_update is True
|
||||
|
||||
view.disconnect_monitor(device="eiger", signal="img")
|
||||
|
||||
assert bridges[-1].closed is True
|
||||
assert view.subscriptions["main"].async_signal_name is None
|
||||
assert view.async_update is False
|
||||
|
||||
@@ -634,7 +632,8 @@ def test_disconnect_clears_async_state(qtbot, mocked_client, monkeypatch):
|
||||
# Connection guardrails
|
||||
|
||||
|
||||
def test_image_setup_rejects_unsupported_signal_class(qtbot, mocked_client):
|
||||
def test_image_setup_rejects_unsupported_signal_class(qtbot, mocked_client, monkeypatch):
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(mocked_client, "eiger", "img", signal_class="Signal", ndim=2)
|
||||
|
||||
@@ -643,9 +642,11 @@ def test_image_setup_rejects_unsupported_signal_class(qtbot, mocked_client):
|
||||
assert view.subscriptions["main"].source is None
|
||||
assert view.subscriptions["main"].monitor_type is None
|
||||
assert view.async_update is False
|
||||
assert bridges == []
|
||||
|
||||
|
||||
def test_image_disconnects_with_missing_entry(qtbot, mocked_client):
|
||||
def test_image_disconnects_with_missing_entry(qtbot, mocked_client, monkeypatch):
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
|
||||
|
||||
@@ -656,85 +657,163 @@ def test_image_disconnects_with_missing_entry(qtbot, mocked_client):
|
||||
view.image(device="eiger", signal=None)
|
||||
assert view.device == ""
|
||||
assert view.signal == ""
|
||||
assert all(bridge.closed for bridge in bridges)
|
||||
|
||||
|
||||
def test_handle_scan_change_clears_buffers_and_resets_crosshair(qtbot, mocked_client, monkeypatch):
|
||||
def test_scan_rollover_resets_accumulation_and_crosshair(qtbot, mocked_client, monkeypatch):
|
||||
"""A new scan id in the update stream starts a fresh 1D accumulation and
|
||||
resets the crosshair (replaces the legacy _handle_scan_change data path)."""
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
view.scan_id = "scan_1"
|
||||
view.main_image.buffer = [np.array([1.0, 2.0])]
|
||||
view.main_image.max_len = 2
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=1, obj_name="async_obj"
|
||||
)
|
||||
view.image(device="eiger", signal="img")
|
||||
|
||||
clear_called = []
|
||||
monkeypatch.setattr(view.main_image, "clear", lambda: clear_called.append(True))
|
||||
view._on_data_update(
|
||||
_make_update(_make_source("eiger", "async_obj", [np.arange(4)]), scan_id="scan_1")
|
||||
)
|
||||
assert view.scan_id == "scan_1"
|
||||
assert view.main_image.raw_data.shape == (1, 4)
|
||||
|
||||
view.hook_crosshair()
|
||||
reset_called = []
|
||||
if view.crosshair is not None:
|
||||
monkeypatch.setattr(view.crosshair, "reset", lambda: reset_called.append(True))
|
||||
monkeypatch.setattr(view.crosshair, "reset", lambda: reset_called.append(True))
|
||||
|
||||
view._handle_scan_change("scan_2")
|
||||
# First update of the new scan: the DataAPI delivers a fresh per-scan series
|
||||
view._on_data_update(
|
||||
_make_update(_make_source("eiger", "async_obj", [np.arange(6)]), scan_id="scan_2")
|
||||
)
|
||||
|
||||
assert view.old_scan_id == "scan_1"
|
||||
assert view.scan_id == "scan_2"
|
||||
assert clear_called == [True]
|
||||
assert view.main_image.buffer == []
|
||||
assert view.main_image.max_len == 0
|
||||
if view.crosshair is not None:
|
||||
assert reset_called == [True]
|
||||
assert reset_called == [True]
|
||||
assert view.main_image.raw_data.shape == (1, 6)
|
||||
|
||||
|
||||
def test_handle_scan_change_reconnects_async(qtbot, mocked_client, monkeypatch):
|
||||
def test_preview_scan_rollover_restricts_display_window(qtbot, mocked_client, monkeypatch):
|
||||
"""Preview streams retain pre-rollover points; a scan change restricts the
|
||||
displayed accumulation to the newest point onward."""
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
view.scan_id = "scan_1"
|
||||
view.async_update = True
|
||||
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=1)
|
||||
view.image(device="waveform1d", signal="img")
|
||||
|
||||
called = []
|
||||
monkeypatch.setattr(view, "_setup_async_image", lambda scan_id: called.append(scan_id))
|
||||
rows = [np.full(5, i, dtype=float) for i in range(3)]
|
||||
view._on_data_update(_preview_update("waveform1d", "img", rows, scan_id="scan_1"))
|
||||
assert view.scan_id == "scan_1"
|
||||
assert view.main_image.raw_data.shape == (3, 5)
|
||||
|
||||
view._handle_scan_change("scan_2")
|
||||
# The device stream keeps the old rows; the newest one belongs to scan_2.
|
||||
rows_after = rows + [np.full(5, 99.0)]
|
||||
view._on_data_update(_preview_update("waveform1d", "img", rows_after, scan_id="scan_2"))
|
||||
|
||||
assert called == ["scan_2"]
|
||||
assert view.old_scan_id == "scan_1"
|
||||
assert view.scan_id == "scan_2"
|
||||
assert view.main_image.raw_data.shape == (1, 5)
|
||||
np.testing.assert_array_equal(view.main_image.raw_data[0], np.full(5, 99.0))
|
||||
|
||||
# Subsequent scan_2 rows extend the new accumulation.
|
||||
rows_more = rows_after + [np.full(5, 100.0)]
|
||||
view._on_data_update(_preview_update("waveform1d", "img", rows_more, scan_id="scan_2"))
|
||||
assert view.main_image.raw_data.shape == (2, 5)
|
||||
|
||||
|
||||
def test_handle_scan_change_same_scan_noop(qtbot, mocked_client, monkeypatch):
|
||||
def test_scan_rollover_same_scan_noop(qtbot, mocked_client, monkeypatch):
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
view.scan_id = "scan_1"
|
||||
view.main_image.buffer = [np.array([1.0])]
|
||||
view.main_image.max_len = 1
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=1, obj_name="async_obj"
|
||||
)
|
||||
view.image(device="eiger", signal="img")
|
||||
|
||||
clear_called = []
|
||||
monkeypatch.setattr(view.main_image, "clear", lambda: clear_called.append(True))
|
||||
|
||||
view._handle_scan_change("scan_1")
|
||||
view._on_data_update(
|
||||
_make_update(_make_source("eiger", "async_obj", [np.arange(3)]), scan_id="scan_1")
|
||||
)
|
||||
view._on_data_update(
|
||||
_make_update(
|
||||
_make_source("eiger", "async_obj", [np.arange(3), np.arange(3)]), scan_id="scan_1"
|
||||
)
|
||||
)
|
||||
|
||||
assert view.scan_id == "scan_1"
|
||||
assert clear_called == []
|
||||
assert view.main_image.buffer == [np.array([1.0])]
|
||||
assert view.main_image.max_len == 1
|
||||
assert view.old_scan_id is None
|
||||
assert view.main_image.raw_data.shape == (2, 3)
|
||||
|
||||
|
||||
def test_image_data_update_2d(qtbot, mocked_client):
|
||||
def test_image_data_update_2d(qtbot, mocked_client, monkeypatch):
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
test_data = np.random.rand(20, 30)
|
||||
message = {"data": test_data}
|
||||
metadata = {}
|
||||
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
|
||||
bec_image_view.image(device="eiger", signal="img")
|
||||
|
||||
bec_image_view.on_image_update_2d(message, metadata)
|
||||
test_data = np.random.rand(20, 30)
|
||||
bec_image_view._on_data_update(_preview_update("eiger", "img", [test_data]))
|
||||
|
||||
np.testing.assert_array_equal(bec_image_view.main_image.image, test_data)
|
||||
|
||||
|
||||
def test_image_data_update_1d(qtbot, mocked_client):
|
||||
def test_image_data_update_1d(qtbot, mocked_client, monkeypatch):
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=1)
|
||||
bec_image_view.image(device="waveform1d", signal="img")
|
||||
|
||||
waveform1 = np.random.rand(50)
|
||||
waveform2 = np.random.rand(60) # Different length, tests padding logic
|
||||
metadata = {"scan_id": "scan_test"}
|
||||
|
||||
bec_image_view.on_image_update_1d({"data": waveform1}, metadata)
|
||||
bec_image_view._on_data_update(_preview_update("waveform1d", "img", [waveform1]))
|
||||
assert bec_image_view.main_image.raw_data.shape == (1, 50)
|
||||
|
||||
bec_image_view.on_image_update_1d({"data": waveform2}, metadata)
|
||||
bec_image_view._on_data_update(_preview_update("waveform1d", "img", [waveform1, waveform2]))
|
||||
assert bec_image_view.main_image.raw_data.shape == (2, 60)
|
||||
|
||||
|
||||
def test_async_add_slice_displays_accumulated_rows(qtbot, mocked_client, monkeypatch):
|
||||
"""add_slice sources deliver one accumulated row per ordinal; shorter rows
|
||||
are zero-padded to the longest one."""
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=1, obj_name="async_obj"
|
||||
)
|
||||
view.image(device="eiger", signal="img")
|
||||
|
||||
source = _make_source(
|
||||
"eiger",
|
||||
"async_obj",
|
||||
[[1.0, 2.0, 3.0], [4.0]],
|
||||
metadata={"async_update_type": "add_slice", "max_shape": [None, None]},
|
||||
)
|
||||
view._on_data_update(_make_update(source))
|
||||
|
||||
np.testing.assert_array_equal(
|
||||
view.main_image.raw_data, np.array([[1.0, 2.0, 3.0], [4.0, 0.0, 0.0]])
|
||||
)
|
||||
|
||||
|
||||
def test_async_replace_displays_current_state(qtbot, mocked_client, monkeypatch):
|
||||
"""replace sources expose a single element: the current full state."""
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
|
||||
)
|
||||
view.image(device="eiger", signal="img")
|
||||
|
||||
frame = np.arange(12, dtype=float).reshape(3, 4)
|
||||
source = _make_source(
|
||||
"eiger",
|
||||
"async_obj",
|
||||
[frame],
|
||||
ordinals=(0,),
|
||||
metadata={"async_update_type": "replace", "max_shape": [3, 4]},
|
||||
)
|
||||
view._on_data_update(_make_update(source))
|
||||
|
||||
np.testing.assert_array_equal(view.main_image.image, frame)
|
||||
|
||||
|
||||
##############################################
|
||||
# Toolbar and Actions Tests
|
||||
|
||||
@@ -826,6 +905,7 @@ def test_setting_vrange_with_colorbar(qtbot, mocked_client, colorbar_type):
|
||||
|
||||
|
||||
def test_setup_image_from_toolbar(qtbot, mocked_client, monkeypatch):
|
||||
bridges = _fake_bridge_factory(monkeypatch)
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
|
||||
_set_signal_config(mocked_client, "eiger", "img", signal_class="PreviewSignal", ndim=2)
|
||||
@@ -1134,7 +1214,7 @@ def test_roi_plot_data_from_image(qtbot, mocked_client):
|
||||
|
||||
# Provide deterministic 2D data
|
||||
test_data = np.arange(25).reshape(5, 5)
|
||||
bec_image_view.on_image_update_2d({"data": test_data}, {})
|
||||
bec_image_view._render_image_data(test_data)
|
||||
|
||||
# Activate ROI crosshair
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
@@ -1164,7 +1244,7 @@ def test_roi_plots_ignore_rgb_images_and_clear_stale_curves(qtbot, mocked_client
|
||||
"""RGB images have vector-valued pixels and cannot produce scalar profile curves."""
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
scalar_image = np.arange(25).reshape(5, 5)
|
||||
bec_image_view.on_image_update_2d({"data": scalar_image}, {})
|
||||
bec_image_view._render_image_data(scalar_image)
|
||||
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
@@ -1177,7 +1257,7 @@ def test_roi_plots_ignore_rgb_images_and_clear_stale_curves(qtbot, mocked_client
|
||||
assert bec_image_view.y_roi_pinned is not None
|
||||
|
||||
rgb_image = np.zeros((5, 5, 3), dtype=np.uint8)
|
||||
bec_image_view.on_image_update_2d({"data": rgb_image}, {})
|
||||
bec_image_view._render_image_data(rgb_image)
|
||||
|
||||
assert bec_image_view._compute_image_slices(bec_image_view.main_image, 2, 3) is None
|
||||
assert bec_image_view.x_roi_curve is None
|
||||
@@ -1194,7 +1274,7 @@ def test_pinned_roi_profiles_freeze_and_clear(qtbot, mocked_client):
|
||||
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
test_data = np.arange(25).reshape(5, 5)
|
||||
bec_image_view.on_image_update_2d({"data": test_data}, {})
|
||||
bec_image_view._render_image_data(test_data)
|
||||
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
@@ -1231,7 +1311,7 @@ def test_pinned_roi_profiles_keep_style_on_theme_change(qtbot, mocked_client):
|
||||
import numpy as np
|
||||
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
|
||||
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(50)
|
||||
@@ -1253,7 +1333,7 @@ def test_pin_survives_scan_reset(qtbot, mocked_client):
|
||||
import numpy as np
|
||||
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
|
||||
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(50)
|
||||
@@ -1263,7 +1343,7 @@ def test_pin_survives_scan_reset(qtbot, mocked_client):
|
||||
pin_point = bec_image_view.crosshair.pinned_point
|
||||
assert pin_point is not None
|
||||
|
||||
# Image._handle_scan_change calls crosshair.reset() on each new scan id.
|
||||
# Image._handle_scan_rollover calls crosshair.reset() on each new scan id.
|
||||
bec_image_view.crosshair.reset()
|
||||
|
||||
# The pin marker and the frozen reference profiles must still be there.
|
||||
@@ -1279,7 +1359,7 @@ def test_pin_and_profiles_restored_after_roi_toggle(qtbot, mocked_client):
|
||||
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
test_data = np.arange(25).reshape(5, 5)
|
||||
bec_image_view.on_image_update_2d({"data": test_data}, {})
|
||||
bec_image_view._render_image_data(test_data)
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(50)
|
||||
@@ -1311,7 +1391,7 @@ def test_detached_pin_can_be_removed_with_right_click(qtbot, mocked_client, monk
|
||||
from qtpy.QtWidgets import QMenu
|
||||
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
|
||||
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(50)
|
||||
@@ -1341,7 +1421,7 @@ def test_pinned_profiles_follow_image_updates(qtbot, mocked_client):
|
||||
exactly like the pin's intensity label."""
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
test_data = np.arange(25, dtype=float).reshape(5, 5)
|
||||
bec_image_view.on_image_update_2d({"data": test_data}, {})
|
||||
bec_image_view._render_image_data(test_data)
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(50)
|
||||
@@ -1351,7 +1431,7 @@ def test_pinned_profiles_follow_image_updates(qtbot, mocked_client):
|
||||
np.testing.assert_array_equal(x_pinned, test_data[:, 3])
|
||||
|
||||
updated = test_data + 100.0
|
||||
bec_image_view.on_image_update_2d({"data": updated}, {})
|
||||
bec_image_view._render_image_data(updated)
|
||||
|
||||
qtbot.waitUntil(
|
||||
lambda: np.array_equal(bec_image_view.x_roi_pinned.getData()[1], updated[:, 3]), timeout=500
|
||||
@@ -1364,7 +1444,7 @@ def test_detached_pin_profiles_follow_image_updates(qtbot, mocked_client):
|
||||
"""Pinned profiles keep following image data while the crosshair is toggled off."""
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
test_data = np.arange(25, dtype=float).reshape(5, 5)
|
||||
bec_image_view.on_image_update_2d({"data": test_data}, {})
|
||||
bec_image_view._render_image_data(test_data)
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(50)
|
||||
@@ -1375,7 +1455,7 @@ def test_detached_pin_profiles_follow_image_updates(qtbot, mocked_client):
|
||||
assert bec_image_view.x_roi_pinned is not None
|
||||
|
||||
updated = test_data + 100.0
|
||||
bec_image_view.on_image_update_2d({"data": updated}, {})
|
||||
bec_image_view._render_image_data(updated)
|
||||
|
||||
qtbot.waitUntil(
|
||||
lambda: np.array_equal(bec_image_view.x_roi_pinned.getData()[1], updated[:, 3]), timeout=500
|
||||
@@ -1387,7 +1467,7 @@ def test_crosshair_moves_update_profiles_immediately(qtbot, mocked_client):
|
||||
them at the current crosshair position."""
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
test_data = np.arange(25, dtype=float).reshape(5, 5)
|
||||
bec_image_view.on_image_update_2d({"data": test_data}, {})
|
||||
bec_image_view._render_image_data(test_data)
|
||||
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
|
||||
switch.actions["crosshair_roi"].action.trigger()
|
||||
qtbot.wait(50)
|
||||
@@ -1402,7 +1482,7 @@ def test_crosshair_moves_update_profiles_immediately(qtbot, mocked_client):
|
||||
|
||||
# New frames refresh the live profile at the crosshair position.
|
||||
updated = test_data + 100.0
|
||||
bec_image_view.on_image_update_2d({"data": updated}, {})
|
||||
bec_image_view._render_image_data(updated)
|
||||
np.testing.assert_array_equal(bec_image_view.x_roi_curve.getData()[1], updated[:, 2])
|
||||
|
||||
|
||||
@@ -1411,7 +1491,7 @@ def test_live_label_intensity_updates_on_image_update(qtbot, mocked_client):
|
||||
not only when the mouse moves."""
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
test_data = np.arange(25, dtype=float).reshape(5, 5)
|
||||
bec_image_view.on_image_update_2d({"data": test_data}, {})
|
||||
bec_image_view._render_image_data(test_data)
|
||||
bec_image_view.hook_crosshair()
|
||||
bec_image_view.crosshair.plot_item.vb.setRange(
|
||||
xRange=(0, test_data.shape[0]), yRange=(0, test_data.shape[1]), padding=0
|
||||
@@ -1420,14 +1500,14 @@ def test_live_label_intensity_updates_on_image_update(qtbot, mocked_client):
|
||||
bec_image_view.crosshair.mouse_moved(manual_pos=(2.5, 3.5))
|
||||
assert "Intensity: 13.000" in bec_image_view.crosshair.coord_label.toPlainText()
|
||||
|
||||
bec_image_view.on_image_update_2d({"data": test_data + 100.0}, {})
|
||||
bec_image_view._render_image_data(test_data + 100.0)
|
||||
assert "Intensity: 113.000" in bec_image_view.crosshair.coord_label.toPlainText()
|
||||
|
||||
|
||||
def test_active_pin_label_intensity_updates_on_image_update(qtbot, mocked_client):
|
||||
"""Pinned crosshair label intensity follows image updates at the pinned position."""
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
|
||||
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
|
||||
bec_image_view.hook_crosshair()
|
||||
|
||||
bec_image_view.crosshair.set_pin(2.0, 3.0)
|
||||
@@ -1435,7 +1515,7 @@ def test_active_pin_label_intensity_updates_on_image_update(qtbot, mocked_client
|
||||
"pin (2.500, 3.500)\nIntensity: 13.000"
|
||||
)
|
||||
|
||||
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5) + 100}, {})
|
||||
bec_image_view._render_image_data(np.arange(25).reshape(5, 5) + 100)
|
||||
|
||||
qtbot.waitUntil(
|
||||
lambda: bec_image_view.crosshair.pinned_label.toPlainText()
|
||||
@@ -1449,7 +1529,7 @@ def test_image_update_does_not_replay_active_crosshair_mouse_handling(
|
||||
):
|
||||
"""Image updates refresh a pin without re-snapping or emitting live crosshair updates."""
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
|
||||
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
|
||||
bec_image_view.hook_crosshair()
|
||||
bec_image_view.crosshair.set_pin(2.0, 3.0)
|
||||
|
||||
@@ -1460,7 +1540,7 @@ def test_image_update_does_not_replay_active_crosshair_mouse_handling(
|
||||
lambda *args, **kwargs: mouse_moves.append((args, kwargs)),
|
||||
)
|
||||
|
||||
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5) + 100}, {})
|
||||
bec_image_view._render_image_data(np.arange(25).reshape(5, 5) + 100)
|
||||
|
||||
qtbot.waitUntil(
|
||||
lambda: bec_image_view.crosshair.pinned_label.toPlainText()
|
||||
@@ -1473,7 +1553,7 @@ def test_image_update_does_not_replay_active_crosshair_mouse_handling(
|
||||
def test_detached_pin_label_intensity_updates_on_image_update(qtbot, mocked_client):
|
||||
"""Detached pin labels keep following image updates while crosshair is disabled."""
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {})
|
||||
bec_image_view._render_image_data(np.arange(25).reshape(5, 5))
|
||||
bec_image_view.hook_crosshair()
|
||||
bec_image_view.crosshair.set_pin(2.0, 3.0)
|
||||
bec_image_view.unhook_crosshair()
|
||||
@@ -1484,7 +1564,7 @@ def test_detached_pin_label_intensity_updates_on_image_update(qtbot, mocked_clie
|
||||
"pin (2.500, 3.500)\nIntensity: 13.000"
|
||||
)
|
||||
|
||||
bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5) + 200}, {})
|
||||
bec_image_view._render_image_data(np.arange(25).reshape(5, 5) + 200)
|
||||
|
||||
qtbot.waitUntil(
|
||||
lambda: bec_image_view._detached_pin["label"].toPlainText()
|
||||
@@ -1496,7 +1576,7 @@ def test_detached_pin_label_intensity_updates_on_image_update(qtbot, mocked_clie
|
||||
def test_detached_pin_label_omits_scalar_intensity_for_rgb_image(qtbot, mocked_client):
|
||||
"""Detached RGB pin labels only show coordinates because a pixel is an array."""
|
||||
bec_image_view = create_widget(qtbot, Image, client=mocked_client)
|
||||
bec_image_view.on_image_update_2d({"data": np.zeros((5, 5, 3))}, {})
|
||||
bec_image_view._render_image_data(np.zeros((5, 5, 3)))
|
||||
bec_image_view.hook_crosshair()
|
||||
bec_image_view.crosshair.set_pin(2.0, 3.0)
|
||||
bec_image_view.unhook_crosshair()
|
||||
@@ -1588,7 +1668,7 @@ def test_log_scale_does_not_crash_with_full_colorbar(qtbot, mocked_client, data)
|
||||
|
||||
view.log = True
|
||||
# This is the exact path that used to raise "Cannot set range [nan, nan]".
|
||||
view.on_image_update_2d({"data": data}, {})
|
||||
view._render_image_data(data)
|
||||
|
||||
vmin, vmax = view.main_image.v_range
|
||||
assert np.isfinite(vmin) and np.isfinite(vmax)
|
||||
@@ -1604,7 +1684,7 @@ def test_log_scale_all_negative_keeps_finite_image(qtbot, mocked_client):
|
||||
view.log = True
|
||||
data = (-np.abs(np.random.rand(15, 15)) - 1.0).astype(np.float32)
|
||||
|
||||
view.on_image_update_2d({"data": data}, {})
|
||||
view._render_image_data(data)
|
||||
|
||||
assert view.main_image.image is not None
|
||||
assert np.all(np.isfinite(view.main_image.image))
|
||||
@@ -1613,7 +1693,7 @@ def test_log_scale_all_negative_keeps_finite_image(qtbot, mocked_client):
|
||||
def test_set_v_range_ignores_non_finite_levels(qtbot, mocked_client):
|
||||
"""Non-finite v_range requests are rejected rather than forwarded to pg."""
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
view.on_image_update_2d({"data": np.random.rand(10, 10)}, {})
|
||||
view._render_image_data(np.random.rand(10, 10))
|
||||
good = view.main_image.v_range
|
||||
|
||||
view.main_image.set_v_range((np.nan, np.nan))
|
||||
@@ -1643,16 +1723,20 @@ def test_image_processor_log_is_finite_for_non_positive():
|
||||
##############################################
|
||||
|
||||
|
||||
def test_adjust_image_buffer_coerces_list_and_scalar(qtbot, mocked_client):
|
||||
def test_1d_buffer_coerces_list_and_scalar(qtbot, mocked_client, monkeypatch):
|
||||
"""Non-ndarray payloads (python list, 0-d scalar) must not crash the
|
||||
1D buffer accumulation."""
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
image = view.main_image
|
||||
_set_signal_config(mocked_client, "waveform1d", "img", signal_class="PreviewSignal", ndim=1)
|
||||
view.image(device="waveform1d", signal="img")
|
||||
|
||||
buf = view.adjust_image_buffer(image, [1, 2, 3]) # python list
|
||||
assert buf.shape == (1, 3)
|
||||
buf = view.adjust_image_buffer(image, np.array(42.0)) # 0-d scalar
|
||||
assert buf.shape[0] == 2
|
||||
view._on_data_update(_preview_update("waveform1d", "img", [[1, 2, 3]])) # python list
|
||||
assert view.main_image.raw_data.shape == (1, 3)
|
||||
view._on_data_update(
|
||||
_preview_update("waveform1d", "img", [[1, 2, 3], np.array(42.0)]) # 0-d scalar
|
||||
)
|
||||
assert view.main_image.raw_data.shape == (2, 3)
|
||||
|
||||
|
||||
##############################################
|
||||
@@ -1675,3 +1759,25 @@ def test_layer_accessors_safe_after_teardown(qtbot, mocked_client):
|
||||
view.autorange_mode = "max"
|
||||
view._sync_autorange_switch()
|
||||
view._sync_colorbar_levels()
|
||||
|
||||
|
||||
def test_history_numpy_source_renders(qtbot, mocked_client, monkeypatch):
|
||||
"""Regression: numpy-array source columns from the history bulk path must
|
||||
render (a truth-test on source.values raised ValueError, swallowed by
|
||||
SafeSlot, leaving the image blank)."""
|
||||
_fake_bridge_factory(monkeypatch)
|
||||
view = create_widget(qtbot, Image, client=mocked_client)
|
||||
_set_signal_config(
|
||||
mocked_client, "eiger", "img", signal_class="AsyncSignal", ndim=2, obj_name="async_obj"
|
||||
)
|
||||
view.image(device="eiger", signal="img")
|
||||
frame = np.arange(12, dtype=float).reshape(3, 4)
|
||||
source = _make_source(
|
||||
"eiger",
|
||||
"async_obj",
|
||||
np.asarray([frame]),
|
||||
metadata={"async_update_type": "add", "max_shape": [None, None]},
|
||||
as_numpy=True,
|
||||
)
|
||||
view._on_data_update(_make_update(source, reason="history"))
|
||||
np.testing.assert_array_equal(view.main_image.raw_data, frame)
|
||||
|
||||
Reference in New Issue
Block a user