import numpy as np import pyqtgraph as pg import pytest from qtpy.QtCore import QPointF, Qt from qtpy.QtGui import QTransform from bec_widgets.utils.crosshair import Crosshair from bec_widgets.widgets.plots.image.image_item import ImageItem from bec_widgets.widgets.plots.waveform.waveform import Waveform from tests.unit_tests.client_mocks import mocked_client from .conftest import create_widget # pylint: disable = redefined-outer-name class _FakeClickEvent: """Minimal public-API stand-in for a pyqtgraph MouseClickEvent.""" def __init__( self, scene_pos: QPointF, button: Qt.MouseButton = Qt.MouseButton.LeftButton, double: bool = False, ): self._scene_pos = scene_pos self._button = button self._double = double self.accepted = False def button(self): return self._button def scenePos(self): return self._scene_pos def double(self): return self._double def accept(self): self.accepted = True @pytest.fixture def plot_widget_with_crosshair(qtbot): widget = pg.PlotWidget() qtbot.addWidget(widget) qtbot.waitExposed(widget) widget.plot(x=[1, 2, 3], y=[4, 5, 6], name="Curve 1") plot_item = widget.getPlotItem() plot_item.vb.setRange(xRange=(0, 4), yRange=(0, 10), padding=0) crosshair = Crosshair(plot_item=plot_item, precision=3) yield crosshair, plot_item @pytest.fixture def image_widget_with_crosshair(qtbot): widget = pg.PlotWidget() qtbot.addWidget(widget) qtbot.waitExposed(widget) image_item = ImageItem() image_item.setImage(np.random.rand(100, 100)) widget.addItem(image_item) plot_item = widget.getPlotItem() plot_item.vb.setRange(xRange=(0, 100), yRange=(0, 100), padding=0) crosshair = Crosshair(plot_item=plot_item, precision=3) yield crosshair, plot_item def test_mouse_moved_lines(plot_widget_with_crosshair): crosshair, _ = plot_widget_with_crosshair # Simulate mouse movement crosshair.mouse_moved(manual_pos=(2, 5)) # Check that the vertical line is indeed at x=2 assert np.isclose(crosshair.v_line.pos().x(), 2) assert np.isclose(crosshair.h_line.pos().y(), 5) def test_mouse_moved_signals(plot_widget_with_crosshair): crosshair, _ = plot_widget_with_crosshair emitted_values_1D = [] def slot(coordinates): emitted_values_1D.append(coordinates) crosshair.coordinatesChanged1D.connect(slot) crosshair.mouse_moved(manual_pos=(2, 5)) # Assert the expected behavior assert emitted_values_1D == [("Curve 1", 2, 5)] def test_mouse_moved_signals_outside(plot_widget_with_crosshair): crosshair, _ = plot_widget_with_crosshair # Create a slot that will store the emitted values as tuples emitted_values_1D = [] emitted_positions = [] def slot(coordinates): emitted_values_1D.append(coordinates) # Connect the signal to the custom slot crosshair.coordinatesChanged1D.connect(slot) crosshair.crosshairChanged.connect(emitted_positions.append) crosshair.mouse_moved(manual_pos=(2, 5)) emitted_positions.clear() emitted_values_1D.clear() crosshair.mouse_moved(manual_pos=(22, 55)) # Assert the expected behavior assert emitted_values_1D == [] assert emitted_positions == [] assert np.isclose(crosshair.v_line.pos().x(), 2) assert np.isclose(crosshair.h_line.pos().y(), 5) def test_mouse_moved_signals_2D(image_widget_with_crosshair): crosshair, _ = image_widget_with_crosshair emitted_values_2D = [] def slot(coordinates): emitted_values_2D.append(coordinates) crosshair.coordinatesChanged2D.connect(slot) crosshair.mouse_moved(manual_pos=(21.0, 55.0)) assert emitted_values_2D == [("ImageItem", 21, 55)] def test_mouse_moved_signals_2D_outside_image_bounds_clamps_inside_view_range( image_widget_with_crosshair, ): crosshair, plot_item = image_widget_with_crosshair plot_item.vb.setRange(xRange=(0, 300), yRange=(0, 600), padding=0) emitted_values_2D = [] def slot(coordinates): emitted_values_2D.append(coordinates) crosshair.coordinatesChanged2D.connect(slot) crosshair.mouse_moved(manual_pos=(220.0, 555.0)) assert emitted_values_2D == [("ImageItem", 99, 99)] def test_mouse_moved_signals_2D_outside_view_range_ignored(image_widget_with_crosshair): crosshair, _ = image_widget_with_crosshair emitted_values_2D = [] emitted_positions = [] crosshair.coordinatesChanged2D.connect(emitted_values_2D.append) crosshair.crosshairChanged.connect(emitted_positions.append) crosshair.mouse_moved(manual_pos=(21.0, 55.0)) emitted_positions.clear() emitted_values_2D.clear() crosshair.mouse_moved(manual_pos=(220.0, 555.0)) assert emitted_values_2D == [] assert emitted_positions == [] assert np.isclose(crosshair.v_line.pos().x(), 21.0) assert np.isclose(crosshair.h_line.pos().y(), 55.0) def test_marker_positions_after_mouse_move(plot_widget_with_crosshair): crosshair, _ = plot_widget_with_crosshair crosshair.mouse_moved(manual_pos=(2, 5)) marker = crosshair.marker_moved_1d["Curve 1"] marker_x, marker_y = marker.getData() assert marker_x == [2] assert marker_y == [5] def test_scale_emitted_coordinates(plot_widget_with_crosshair): crosshair, _ = plot_widget_with_crosshair x, y = crosshair.scale_emitted_coordinates(2, 5) assert x == 2 assert y == 5 crosshair.is_log_x = True crosshair.is_log_y = True x, y = crosshair.scale_emitted_coordinates(np.log10(2), np.log10(5)) assert np.isclose(x, 2) assert np.isclose(y, 5) def test_crosshair_changed_signal(plot_widget_with_crosshair): crosshair, _ = plot_widget_with_crosshair emitted_positions = [] def slot(position): emitted_positions.append(position) crosshair.crosshairChanged.connect(slot) crosshair.mouse_moved(manual_pos=(2, 5)) x, y = emitted_positions[0] assert np.isclose(x, 2) assert np.isclose(y, 5) def test_crosshair_clicked_signal(plot_widget_with_crosshair): crosshair, plot_item = plot_widget_with_crosshair emitted_positions = [] emitted_view_positions = [] def slot(position): emitted_positions.append(position) crosshair.crosshairClicked.connect(slot) crosshair.positionClicked.connect(emitted_view_positions.append) crosshair.is_log_x = True crosshair.is_log_y = True plot_item.vb.setRange(xRange=(0, 1), yRange=(0, 1), padding=0) known_view_point = QPointF(np.log10(2), np.log10(5)) pos_in_scene = plot_item.vb.mapViewToScene(known_view_point) crosshair.mouse_clicked(_FakeClickEvent(pos_in_scene)) x, y = emitted_positions[0] view_x, view_y = emitted_view_positions[0] assert np.isclose(x, 2) assert np.isclose(y, 5) assert np.isclose(view_x, known_view_point.x()) assert np.isclose(view_y, known_view_point.y()) def test_update_coord_label_1D(plot_widget_with_crosshair): crosshair, _ = plot_widget_with_crosshair # Provide a test position pos = (10, 20) crosshair.update_coord_label(pos) expected_text = f"({10:.3f}, {20:.3f})" # Verify that the coordinate label shows only the 1D coordinates (no intensity line) assert crosshair.coord_label.toPlainText() == expected_text label_pos = crosshair.coord_label.pos() assert np.isclose(label_pos.x(), 10) assert np.isclose(label_pos.y(), 20) assert crosshair.coord_label.isVisible() def test_update_coord_label_2D(image_widget_with_crosshair): crosshair, plot_item = image_widget_with_crosshair known_image = np.array([[10, 20], [30, 40]], dtype=float) for item in plot_item.items: if isinstance(item, pg.ImageItem): item.setImage(known_image) pos = (0.5, 1.2) crosshair.update_coord_label(pos) ix = int(np.clip(0.5, 0, known_image.shape[0] - 1)) # 0 iy = int(np.clip(1.2, 0, known_image.shape[1] - 1)) # 1 intensity = known_image[ix, iy] # Expected: 20 expected_text = f"({0.5:.3f}, {1.2:.3f})\nIntensity: {intensity:.3f}" assert crosshair.coord_label.toPlainText() == expected_text label_pos = crosshair.coord_label.pos() assert np.isclose(label_pos.x(), 0.5) assert np.isclose(label_pos.y(), 1.2) assert crosshair.coord_label.isVisible() def test_rgb_image_labels_omit_scalar_intensity(image_widget_with_crosshair): """RGB pixels are arrays, so live and pinned labels only show coordinates.""" crosshair, plot_item = image_widget_with_crosshair rgb_image = np.arange(12).reshape(2, 2, 3) for item in plot_item.items: if isinstance(item, pg.ImageItem): item.setImage(rgb_image) crosshair.update_coord_label((0.5, 1.2)) assert crosshair.coord_label.toPlainText() == "(0.500, 1.200)" crosshair.set_pin(0.0, 1.0) assert crosshair.pinned_label.toPlainText() == "pin (0.500, 1.500)" def test_update_markers_on_image_change_accepts_image_without_transform( image_widget_with_crosshair, ): crosshair, plot_item = image_widget_with_crosshair image_item = next(item for item in plot_item.items if isinstance(item, pg.ImageItem)) image_item.image_transform = None crosshair.update_markers_on_image_change() assert crosshair.marker_2d_row.transform() == QTransform() assert crosshair.marker_2d_col.transform() == QTransform() def test_crosshair_precision_properties(plot_widget_with_crosshair): """ Ensure Crosshair.precision and Crosshair.min_precision behave correctly and that _current_precision() reflects changes immediately. """ crosshair, plot_item = plot_widget_with_crosshair assert crosshair.precision == 3 assert crosshair._current_precision() == 3 crosshair.precision = None plot_item.vb.setXRange(0, 1_000, padding=0) plot_item.vb.setYRange(0, 1_000, padding=0) assert crosshair._current_precision() == crosshair.min_precision == 2 # default floor crosshair.min_precision = 5 assert crosshair._current_precision() == 5 crosshair.precision = 1 assert crosshair._current_precision() == 1 def test_crosshair_precision_properties_image(image_widget_with_crosshair): """ The same precision/min_precision behaviour must apply for crosshairs attached to ImageItem-based plots. """ crosshair, plot_item = image_widget_with_crosshair assert crosshair.precision == 3 assert crosshair._current_precision() == 3 crosshair.precision = None plot_item.vb.setXRange(0, 1_000, padding=0) plot_item.vb.setYRange(0, 1_000, padding=0) assert crosshair._current_precision() == crosshair.min_precision == 2 crosshair.min_precision = 6 assert crosshair._current_precision() == 6 crosshair.precision = 2 assert crosshair._current_precision() == 2 def test_get_transformed_position(plot_widget_with_crosshair): """Test that _get_transformed_position correctly transforms coordinates.""" crosshair, _ = plot_widget_with_crosshair # Create a simple transform transform = QTransform() transform.translate(10, 20) # Origin is now at (10, 20) # Test coordinates x, y = 5, 8 # Get the transformed position row, col = crosshair._get_transformed_position(x, y, transform) # Calculate expected values: # row should be the y-offset from origin after transform # col should be the x-offset from origin after transform expected_row = QPointF(0, 8) # y direction offset expected_col = QPointF(5, 0) # x direction offset # Check that the results match expectations assert row == expected_row assert col == expected_col def test_get_transformed_position_with_scale(plot_widget_with_crosshair): """Test that _get_transformed_position correctly handles scaling transformations.""" crosshair, _ = plot_widget_with_crosshair # Create a transform with scaling transform = QTransform() transform.translate(10, 20) # Origin is now at (10, 20) transform.scale(2, 3) # Scale x by 2 and y by 3 # Test coordinates x, y = 5, 8 # Get the transformed position row, col = crosshair._get_transformed_position(x, y, transform) # Calculate expected values with scaling applied: # For a scale transform, the offsets should be multiplied by the scale factors expected_row = QPointF(0, 8 * 3) # y direction offset with scale factor 3 expected_col = QPointF(5 * 2, 0) # x direction offset with scale factor 2 # Check that the results match expectations assert row == expected_row assert col == expected_col def test_ignore_invisible_curves_on_move(qtbot, mocked_client): wf = create_widget(qtbot, Waveform, client=mocked_client) c0 = wf.plot(x=[1, 2, 3], y=[1, 4, 9], name="Curve_0") c1 = wf.plot(x=[1, 2, 3], y=[2, 5, 10], name="Curve_1") wf.hook_crosshair() wf.crosshair.plot_item.vb.setRange(xRange=(0, 4), yRange=(0, 10), padding=0) # # Simulate a mouse move at (2,5) # 1) Both curves visible: expect markers for both wf.crosshair.clear_markers() wf.crosshair.mouse_moved(manual_pos=(2, 5)) assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0", "Curve_1"} # 2) Hide Curve B and repeat: only Curve_0 should remain c1.setVisible(False) wf.crosshair.clear_markers() wf.crosshair.mouse_moved(manual_pos=(2, 5)) qtbot.wait(200) assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0"} ############################################### # Pinned marker (click to pin, remove gestures) ############################################### def test_set_pin_1d_creates_marker_and_emits(plot_widget_with_crosshair): crosshair, _ = plot_widget_with_crosshair pinned = [] crosshair.coordinatesPinned1D.connect(pinned.append) assert crosshair.pinned_point is None crosshair.set_pin(2, 5) assert crosshair.pinned_point is not None assert crosshair.pinned_pos is not None assert len(pinned) == 1 _, px, py = pinned[0] assert np.isclose(px, 2) and np.isclose(py, 5) def test_pin_draws_dashed_crosshair_lines(plot_widget_with_crosshair): crosshair, plot_item = plot_widget_with_crosshair crosshair.set_pin(2, 5) # A frozen, dashed crosshair (two infinite lines) is drawn at the pin. assert crosshair.pinned_v_line is not None assert crosshair.pinned_h_line is not None assert np.isclose(crosshair.pinned_v_line.value(), 2) assert np.isclose(crosshair.pinned_h_line.value(), 5) assert crosshair.pinned_v_line.pen.style() == Qt.PenStyle.DashLine assert crosshair.pinned_v_line in plot_item.items assert crosshair.pinned_h_line in plot_item.items crosshair.clear_pin() assert crosshair.pinned_v_line is None assert crosshair.pinned_h_line is None def test_clear_pin_emits_only_when_pinned(plot_widget_with_crosshair): crosshair, _ = plot_widget_with_crosshair cleared = [] crosshair.pinCleared.connect(lambda: cleared.append(True)) crosshair.set_pin(2, 5) crosshair.clear_pin() assert crosshair.pinned_point is None assert crosshair.pinned_v_line is None assert crosshair.pinned_h_line is None assert crosshair.pinned_pos is None assert cleared == [True] # Clearing again is a no-op and must not re-emit. crosshair.clear_pin() assert cleared == [True] def test_single_click_pins(qtbot, plot_widget_with_crosshair): crosshair, plot_item = plot_widget_with_crosshair graphics_view = plot_item.vb.scene().views()[0] qtbot.waitExposed(graphics_view) pos = graphics_view.mapFromScene(plot_item.vb.mapViewToScene(QPointF(2, 5))) qtbot.mouseClick(graphics_view.viewport(), Qt.LeftButton, pos=pos) assert crosshair.pinned_point is not None assert crosshair.pinned_pos is not None def test_reclick_on_pin_removes_it(plot_widget_with_crosshair): crosshair, plot_item = plot_widget_with_crosshair crosshair.set_pin(2, 5) assert crosshair.pinned_point is not None # A left click that lands on the existing pin toggles it off. scene_pos = plot_item.vb.mapViewToScene(QPointF(*crosshair.pinned_pos)) crosshair.mouse_clicked(_FakeClickEvent(scene_pos, button=Qt.LeftButton)) assert crosshair.pinned_point is None def test_double_click_clears_pin(plot_widget_with_crosshair): crosshair, plot_item = plot_widget_with_crosshair crosshair.set_pin(2, 5) assert crosshair.pinned_point is not None scene_pos = plot_item.vb.mapViewToScene(QPointF(2, 5)) crosshair.mouse_clicked(_FakeClickEvent(scene_pos, double=True)) assert crosshair.pinned_point is None def test_right_click_on_pin_removes_via_menu(monkeypatch, plot_widget_with_crosshair): """Right-clicks are handled by the pin item itself (accepting the event there is what keeps pyqtgraph's plot context menu from opening on top).""" from qtpy.QtWidgets import QMenu crosshair, _ = plot_widget_with_crosshair crosshair.set_pin(2, 5) assert crosshair.pinned_point is not None # Choose the (only) "Remove pinned marker" action without showing a real menu. monkeypatch.setattr(QMenu, "exec_", lambda self, *a, **k: self.actions()[0]) event = _FakeClickEvent(None, button=Qt.RightButton) crosshair.pinned_point.mouseClickEvent(event) assert crosshair.pinned_point is None assert event.accepted is True def test_right_click_on_pin_menu_cancelled_keeps_pin(monkeypatch, plot_widget_with_crosshair): from qtpy.QtWidgets import QMenu crosshair, _ = plot_widget_with_crosshair crosshair.set_pin(2, 5) monkeypatch.setattr(QMenu, "exec_", lambda self, *a, **k: None) # menu dismissed event = _FakeClickEvent(None, button=Qt.RightButton) crosshair.pinned_point.mouseClickEvent(event) assert crosshair.pinned_point is not None assert event.accepted is True def test_right_click_without_pin_is_ignored(plot_widget_with_crosshair): crosshair, plot_item = plot_widget_with_crosshair scene_pos = plot_item.vb.mapViewToScene(QPointF(2, 5)) event = _FakeClickEvent(scene_pos, button=Qt.RightButton) crosshair.mouse_clicked(event) # No pin -> nothing handled at scene level, the event is left for # pyqtgraph's own context menu. assert event.accepted is False def test_log_mode_change_clears_pin(plot_widget_with_crosshair): """The pin lives in view coordinates; toggling log mode must drop it instead of leaving it at a now-meaningless position.""" crosshair, plot_item = plot_widget_with_crosshair crosshair.set_pin(2, 5) assert crosshair.pinned_point is not None plot_item.ctrl.logYCheck.setChecked(True) assert crosshair.pinned_point is None assert crosshair.pinned_pos is None def test_adopted_pin_rebinds_removal_callback(monkeypatch, plot_widget_with_crosshair): """After release/adopt (crosshair toggled off/on) the pin's context-menu removal must act on the adopting crosshair, not the deleted one.""" from qtpy.QtWidgets import QMenu crosshair, _ = plot_widget_with_crosshair crosshair.set_pin(2, 5) state = crosshair.release_pin() # While detached there is no owner: the menu is disabled. assert state["point"]._on_remove is None crosshair.adopt_pin(state) assert crosshair.pinned_point is not None monkeypatch.setattr(QMenu, "exec_", lambda self, *a, **k: self.actions()[0]) event = _FakeClickEvent(None, button=Qt.RightButton) crosshair.pinned_point.mouseClickEvent(event) assert crosshair.pinned_point is None def test_set_pin_2d_emits_pixel_coordinates(image_widget_with_crosshair): crosshair, _ = image_widget_with_crosshair pinned = [] crosshair.coordinatesPinned2D.connect(pinned.append) crosshair.set_pin(40, 60) assert crosshair.pinned_point is not None assert len(pinned) == 1 _, px, py = pinned[0] assert (px, py) == (40, 60) def test_set_pin_2d_label_includes_intensity(image_widget_with_crosshair): """The pin label includes the image intensity at the pinned pixel and follows image changes through update_on_image_change (same mechanism as the live label).""" crosshair, plot_item = image_widget_with_crosshair image = np.arange(10_000, dtype=float).reshape(100, 100) for item in plot_item.items: if isinstance(item, pg.ImageItem): item.setImage(image) crosshair.set_pin(40, 60) assert crosshair.pinned_label is not None assert crosshair.pinned_label.toPlainText() == "pin (40.500, 60.500)\nIntensity: 4060.000" for item in plot_item.items: if isinstance(item, pg.ImageItem): item.setImage(image + 100.0) crosshair.update_on_image_change() assert crosshair.pinned_label.toPlainText() == "pin (40.500, 60.500)\nIntensity: 4160.000" def test_reset_preserves_pin_cleanup_removes_it(image_widget_with_crosshair): crosshair, _ = image_widget_with_crosshair crosshair.set_pin(40, 60) assert crosshair.pinned_point is not None # A data/scan reset keeps the pin (a persistent annotation). crosshair.reset() assert crosshair.pinned_point is not None assert crosshair.pinned_pos is not None # Full teardown removes it. crosshair.cleanup() assert crosshair.pinned_point is None assert crosshair.pinned_pos is None