Files
eco/tests/test_display_qt.py
Henrik Lemke 44d4400d61 Property grids: setpoint next to the readback, "⋯" item menu per row
Both grids (Qt display_qt, ipywidgets display_widget) now show the
absolute-value entry right after the readback, with step and up/down
tweak buttons after it.

Every row - Adjustable, Detector or sub-assembly - ends in a "⋯" menu
(also on right-click of the name in Qt) offering what the item supports:
its _widget_* views, a live strip chart in the same window/page, the
archiver strip plot window, archiver history (10 min .. 7 days), the
indicator gadgets, its memories, details (channels, unit, limits, doc),
and in Qt copy name / PV names and Find IOC. In eco desktop new views dock
next to the grid; in a notebook they open as closable panels below the
row. All GUI code lives in eco/widgets (item_menu, item_menu_qt,
item_menu_ipy); archiver's plotting is split into _plot_dataframe_on so
the panels can draw into their own figures.

Hardware and archiver access stays off the GUI/kernel thread; matplotlib
renders from threads are serialized; the rows' menus hold the grid only
weakly.
2026-10-30 18:12:05 +01:00

229 lines
7.4 KiB
Python

"""Tests for eco.widgets.display_qt's responsiveness rules: writes off the
GUI thread with a status dot, monitored rows, parallel/bounded reads, and the
tweak step from the IOC."""
import threading
import time
import pytest
pytest.importorskip("qtpy")
from qtpy import QtCore, QtWidgets
from eco.widgets import display_qt
@pytest.fixture
def app():
return QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
def pump(app, seconds, until=None):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
app.processEvents()
if until is not None and until():
return True
time.sleep(0.01)
return until() if until is not None else None
class _Changer:
def __init__(self, duration):
self.done = threading.Event()
self.stopped = False
self._duration = duration
def wait(self):
self.done.wait(self._duration)
def stop(self):
self.stopped = True
self.done.set()
class SlowAdjustable:
name = "slow"
def __init__(self, value=1.0, duration=1.0, error=None):
self.value = value
self.duration = duration
self.error = error
self.targets = []
self.changer = None
def get_current_value(self):
return self.value
def set_target_value(self, value):
if self.error is not None:
raise self.error
self.targets.append(value)
self.changer = _Changer(self.duration)
return self.changer
def dot_of(controls):
return controls.controller.dot
def test_a_click_returns_at_once_and_the_row_shows_the_move(app):
item = SlowAdjustable(duration=5.0)
controls, _ = display_qt._build_item_controls_qt(item, QtWidgets.QLabel("1.0"), 1.0)
up = next(b for b in controls.findChildren(QtWidgets.QPushButton) if b.text() == "▲")
stop = next(b for b in controls.findChildren(QtWidgets.QPushButton) if b.text() == "🛑")
t0 = time.monotonic()
up.click()
assert time.monotonic() - t0 < 0.2 # used to block for up to 5 s
assert pump(app, 2, until=lambda: dot_of(controls).toolTip().startswith("moving"))
assert item.targets == [pytest.approx(1.1)]
stop.click() # clickable during the move
assert pump(app, 2, until=lambda: item.changer.stopped)
assert pump(app, 2, until=lambda: not dot_of(controls).toolTip().startswith("moving"))
@pytest.mark.parametrize(
"error, shown",
[
(PermissionError("mot is in use by queue 'default', task #1 -- not moved."), "in use by queue"),
(RuntimeError("limit switch"), "RuntimeError: limit switch"),
],
)
def test_a_failed_or_blocked_write_shows_red_with_the_reason(app, error, shown):
item = SlowAdjustable(error=error)
controls, _ = display_qt._build_item_controls_qt(item, QtWidgets.QLabel("1.0"), 1.0)
entry = next(e for e in controls.findChildren(QtWidgets.QLineEdit) if e.text() == "1.0")
entry.setText("2.0")
entry.editingFinished.emit()
assert pump(app, 2, until=lambda: shown in dot_of(controls).toolTip())
assert "#d93025" in dot_of(controls).styleSheet()
class FakePV:
def __init__(self, value):
self.value = value
self.puts = []
def get(self, timeout=None):
return self.value
def put(self, value):
self.puts.append(value)
class FakeMotorRecord(SlowAdjustable):
"""Has the IOC tweak fields, like MotorRecord (see tweak_panel.has_ioc_tweak)."""
def __init__(self):
super().__init__(value=3.0, duration=0.0)
self.twv = FakePV(0.25)
self._motor = type("M", (), {"get_pv": lambda _, field: {"TWV": self.twv}.get(field, FakePV(0))})()
def _tweak_ioc(self):
pass
def test_the_tweak_step_comes_from_the_ioc_and_edits_go_back(app):
item = FakeMotorRecord()
controls, _ = display_qt._build_item_controls_qt(item, QtWidgets.QLabel("3.0"), 3.0)
step = controls.findChild(QtWidgets.QLineEdit, "step")
assert pump(app, 2, until=lambda: step.text() == "0.25")
step.setText("0.5")
step.editingFinished.emit()
assert pump(app, 2, until=lambda: item.twv.puts == [0.5])
def test_the_tweak_step_falls_back_to_the_adjustables_own_tweak(app):
item = SlowAdjustable(value=3.0)
item._tweak_instance = type("T", (), {"step_sizes": [0.05]})()
controls, _ = display_qt._build_item_controls_qt(item, QtWidgets.QLabel("3.0"), 3.0)
assert controls.findChild(QtWidgets.QLineEdit, "step").text() == "0.05"
# -- the whole window --------------------------------------------------------------
class MonitoredAdjustable(SlowAdjustable):
"""Pushes updates like a motor record (add_value_callback)."""
def __init__(self, name):
super().__init__(value=0.0, duration=0.0)
self.name = name
self.callbacks = {}
def add_value_callback(self, callback, index=None):
index = len(self.callbacks) + 1
self.callbacks[index] = callback
return index
def clear_value_callback(self, index=None):
self.callbacks.pop(index, None)
def push(self, value):
self.value = value
for callback in list(self.callbacks.values()):
callback(pvname=self.name, value=value)
class HangingAdjustable(SlowAdjustable):
"""Answers only after `delay` (a channel connecting slowly)."""
def __init__(self, name, delay):
super().__init__(value=7.0)
self.name = name
self.delay = delay
def get_current_value(self):
time.sleep(self.delay)
return self.value
class DeadDetector:
name = "dead"
def get_current_value(self):
return None # how a failed CA read comes back
class FakeAssembly:
name = "fake"
def __init__(self, items):
self.items = items
self.status_collection = type("SC", (), {"get_list": lambda _, selection=None: list(items)})()
def display_collection(self):
return list(self.items)
def test_window_opens_fast_monitors_and_reads_in_parallel(app, monkeypatch):
from eco.widgets import live_values
monkeypatch.setattr(live_values, "PREFETCH_DEADLINE", 0.5)
monitored = MonitoredAdjustable("monitored")
slow = HangingAdjustable("slow", delay=1.5)
dead = DeadDetector()
assembly = FakeAssembly([monitored, slow, dead])
t0 = time.monotonic()
gui = display_qt.DisplayQt(assembly, poll_interval=30.0, auto_start=False)
gui._build_window()
try:
assert time.monotonic() - t0 < 1.5 # did not wait for the slow row
rows = {e["item"].name: e for e in gui._entries}
assert rows["slow"]["value_label"].text() == "…"
# monitored: subscribed, and a push shows at once despite the 30 s poll interval
assert pump(app, 3, until=lambda: monitored.callbacks)
monitored.push(4.5)
assert pump(app, 2, until=lambda: rows["monitored"]["value_label"].text() == "4.5")
assert "updated by monitor" in rows["monitored"]["controller"].dot.toolTip()
# the slow row gets its value and then its controls
assert pump(app, 4, until=lambda: rows["slow"]["value_label"].text() == "7.0")
assert not rows["slow"]["needs_controls"]
assert any(b.text() == "▲" for b in rows["slow"]["controls"].findChildren(QtWidgets.QPushButton))
# a channel that returns no value is shown as such
assert pump(app, 2, until=lambda: "not connected" in rows["dead"]["controller"].dot.toolTip())
finally:
gui.stop()
assert pump(app, 2, until=lambda: not monitored.callbacks) # unsubscribed on close