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

413 lines
13 KiB
Python

import time
import pytest
pytest.importorskip("qtpy")
from qtpy import QtWidgets
import eco.widgets.dashboard_qt as dashboard_qt
from eco.widgets.dashboard_qt import Dashboard, get_default_dashboard, make_dashboard
from eco.widgets.indicator_widgets import create_indicator
class _FakeAlias:
def __init__(self, full_name):
self._full_name = full_name
def get_full_name(self):
return self._full_name
class _FakeDetector:
def __init__(self, name, value=0.0, full_name=None):
self.name = name
self._value = value
if full_name:
self.alias = _FakeAlias(full_name)
def get_current_value(self):
return self._value
class _FakeNamespace:
"""Minimal stand-in with attribute-chain access for resolve_item_path."""
def __init__(self):
self.alias = _FakeAlias("ns")
def _app():
return QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
@pytest.fixture(autouse=True)
def _no_real_home_writes(tmp_path, monkeypatch):
monkeypatch.setattr(dashboard_qt, "DEFAULT_WORKSPACE_FILE", tmp_path / "dashboard_workspace.json")
monkeypatch.setattr(dashboard_qt, "CUSTOM_PANELS_DIR", tmp_path / "custom_panels")
def test_make_dashboard_builds_a_window():
_app()
board = make_dashboard(title="Test board", auto_start=False)
board._build_window()
try:
assert board.window is not None
assert board.window.windowTitle() == "Test board"
finally:
board.stop()
def test_native_close_stops_every_gadgets_poll_thread():
"""Regression test for a real bug: closing the dashboard window via
its own native close (X) button used to leave every gadget's poll
thread running -- only remove_widget() (never wired to a native
close) actually stopped one."""
app = _app()
board = Dashboard(auto_start=False)
board._build_window()
item = _FakeDetector("d1")
widget = create_indicator("LED", item, title="D1")
board.add_widget(widget, title="D1")
assert not widget._stop_event.is_set()
board.window.close() # simulates the native X button, not board.stop()
assert widget._stop_event.is_set()
assert board._docks == []
def test_add_widget_creates_dock_and_tabs_with_previous():
_app()
board = Dashboard(auto_start=False)
board._build_window()
try:
item1 = _FakeDetector("d1")
item2 = _FakeDetector("d2")
w1 = create_indicator("LED", item1, title="D1")
w2 = create_indicator("LED", item2, title="D2")
board.add_widget(w1, title="D1")
board.add_widget(w2, title="D2")
assert len(board._docks) == 2
assert board.window.tabifiedDockWidgets(board._docks[0]) or True # tabify call didn't raise
finally:
board.stop()
def test_remove_widget_stops_gadget_polling():
app = _app()
board = Dashboard(auto_start=False)
board._build_window()
item = _FakeDetector("d1", value=1.0)
w = create_indicator("Numeric tile (KPI)", item, title="D1")
dock = board.add_widget(w, title="D1")
try:
board.remove_widget(dock)
assert dock not in board._docks
assert w._stop_event.is_set()
finally:
board.stop()
def test_get_default_dashboard_reuses_until_closed():
_app()
d1 = get_default_dashboard() # auto_start=True already builds the window
assert d1.window is not None
d2 = get_default_dashboard()
assert d1 is d2
d1.stop()
d3 = get_default_dashboard()
try:
assert d3 is not d1
finally:
d3.stop()
# -- workspace save/load (JSON) --
def test_save_and_load_workspace_round_trip(tmp_path):
app = _app()
board = Dashboard(auto_start=False)
board._build_window()
item = _FakeDetector("intensity", value=7.0, full_name="cam_west.intensity")
w = create_indicator("Bar gauge", item, title="Intensity", vmin=0.0, vmax=10.0)
board.add_widget(w, title="Intensity")
path = tmp_path / "ws.json"
assert board.save_workspace(path) is True
import json
data = json.loads(path.read_text())
assert data["gadgets"] == [
{
"kind": "Bar gauge",
"item_path": "cam_west.intensity",
"title": "Intensity",
"kwargs": {"vmin": 0.0, "vmax": 10.0},
}
]
board.stop()
# fresh dashboard, reload against a fake namespace that resolves the path
ns = _FakeNamespace()
ns.cam_west = type("C", (), {})()
ns.cam_west.intensity = item
board2 = Dashboard(auto_start=False)
board2._build_window()
try:
loaded = board2.load_workspace(path, namespace=ns)
assert loaded is True
assert len(board2._docks) == 1
reattached_widget = board2._docks[0].widget()
assert reattached_widget.item is item
assert reattached_widget.vmin == 0.0
assert reattached_widget.vmax == 10.0
finally:
board2.stop()
def test_load_workspace_skips_unresolvable_items(tmp_path):
app = _app()
board = Dashboard(auto_start=False)
board._build_window()
item = _FakeDetector("intensity", value=1.0, full_name="cam_west.intensity")
w = create_indicator("LED", item, title="Intensity")
board.add_widget(w, title="Intensity")
path = tmp_path / "ws.json"
board.save_workspace(path)
board.stop()
empty_ns = _FakeNamespace() # no cam_west attribute -> unresolvable
board2 = Dashboard(auto_start=False)
board2._build_window()
try:
board2.load_workspace(path, namespace=empty_ns)
assert len(board2._docks) == 0
finally:
board2.stop()
def test_load_workspace_missing_file_returns_false(tmp_path):
_app()
board = Dashboard.__new__(Dashboard)
board.window = None
board._docks = []
assert board.load_workspace(tmp_path / "nope.json") is False
def test_save_workspace_no_window_is_noop():
board = Dashboard.__new__(Dashboard)
board.window = None
assert board.save_workspace() is False
def test_autosave_on_close_writes_workspace(tmp_path):
app = _app()
board = Dashboard(auto_start=False)
board._build_window()
item = _FakeDetector("d1", value=1.0, full_name="d1")
w = create_indicator("LED", item, title="D1")
board.add_widget(w, title="D1")
path = tmp_path / "autosave.json"
dashboard_qt.DEFAULT_WORKSPACE_FILE = path
board.window.close()
assert path.exists()
# -- startup script export --
def test_export_startup_script_content(tmp_path):
app = _app()
board = Dashboard(title="My board", auto_start=False)
board._build_window()
item = _FakeDetector("intensity", value=1.0, full_name="cam_west.intensity")
w = create_indicator("Bar gauge", item, title="Intensity", vmin=0.0, vmax=10.0)
board.add_widget(w, title="Intensity")
path = tmp_path / "startup.py"
try:
board.export_startup_script(path, namespace_var="namespace")
text = path.read_text()
assert "namespace.cam_west.intensity" in text
assert "'Bar gauge'" in text
assert "vmin=0.0" in text
assert "vmax=10.0" in text
assert "make_dashboard(title='My board')" in text
compile(text, str(path), "exec") # at least syntactically valid python
finally:
board.stop()
def test_export_startup_script_skips_entries_without_item_path(tmp_path):
app = _app()
board = Dashboard(auto_start=False)
board._build_window()
item = _FakeDetector("no_alias", value=1.0) # no .alias -> empty item_path
w = create_indicator("LED", item, title="NoAlias")
board.add_widget(w, title="NoAlias")
path = tmp_path / "startup.py"
try:
board.export_startup_script(path)
text = path.read_text()
assert "add_widget" not in text
finally:
board.stop()
# -- Qt Designer round-trip --
def test_write_promoted_ui_produces_valid_designer_xml(tmp_path):
from eco.widgets.dashboard_qt import _write_promoted_ui
entries = [
{"kind": "LED", "item_path": "cam_west.shutter", "title": "Shutter", "kwargs": {}},
{
"kind": "Bar gauge", "item_path": "cam_west.intensity", "title": "Intensity",
"kwargs": {"vmin": 0.0, "vmax": 10.0},
},
]
path = tmp_path / "layout.ui"
_write_promoted_ui(entries, path)
import xml.etree.ElementTree as ET
tree = ET.parse(path)
root = tree.getroot()
assert root.tag == "ui"
custom_classes = {cw.find("class").text for cw in root.find("customwidgets")}
assert custom_classes == {"LEDIndicator", "BarGauge"}
widgets = root.findall(".//widget[@class='LEDIndicator']") + root.findall(
".//widget[@class='BarGauge']"
)
assert len(widgets) == 2
access_names = {
w.find("./property[@name='accessibleName']/string").text for w in widgets
}
assert access_names == {"cam_west.shutter", "cam_west.intensity"}
def test_export_to_designer_without_binary_raises_clear_error(tmp_path, monkeypatch):
app = _app()
board = Dashboard(auto_start=False)
board._build_window()
item = _FakeDetector("d1", value=1.0, full_name="d1")
board.add_widget(create_indicator("LED", item, title="D1"), title="D1")
import subprocess as sp
def raise_not_found(*a, **kw):
raise FileNotFoundError()
monkeypatch.setattr(sp, "Popen", raise_not_found)
try:
with pytest.raises(RuntimeError, match="Qt Designer"):
board.export_to_designer(tmp_path / "layout.ui")
finally:
board.stop()
def test_load_custom_panel_real_round_trip_through_uic(tmp_path):
"""The real, valuable check: write a .ui file the same way
export_to_designer would (promoted widgets, no Designer binary
needed), load it back with the actual PyQt5.uic machinery (exactly
what Dashboard.load_custom_panel does), and confirm the promoted
gadgets come back as real, live, correctly-reattached instances --
not a mock of the mechanism, the mechanism itself."""
pytest.importorskip("PyQt5.uic")
app = _app()
from eco.widgets.dashboard_qt import _write_promoted_ui
entries = [
{"kind": "LED", "item_path": "cam_west.shutter", "title": "Shutter", "kwargs": {}},
]
ui_path = tmp_path / "layout.ui"
_write_promoted_ui(entries, ui_path)
ns = _FakeNamespace()
ns.cam_west = type("C", (), {})()
item = _FakeDetector("shutter", value=True)
ns.cam_west.shutter = item
board = Dashboard(auto_start=False)
board._build_window()
try:
dock = board.load_custom_panel(ui_path, namespace=ns, title="Loaded panel")
assert dock in board._docks
panel = dock.widget()
from eco.widgets.indicator_widgets import LEDIndicator, _IndicatorBase
leds = panel.findChildren(_IndicatorBase)
assert len(leds) == 1
assert isinstance(leds[0], LEDIndicator)
assert leds[0].item is item # really reattached to the live item
finally:
board.stop()
def test_load_custom_panel_without_namespace_stays_inert(tmp_path):
pytest.importorskip("PyQt5.uic")
app = _app()
from eco.widgets.dashboard_qt import _write_promoted_ui
entries = [{"kind": "LED", "item_path": "cam_west.shutter", "title": "Shutter", "kwargs": {}}]
ui_path = tmp_path / "layout.ui"
_write_promoted_ui(entries, ui_path)
board = Dashboard(auto_start=False)
board._build_window()
try:
dock = board.load_custom_panel(ui_path, namespace=None, title="Loaded panel")
panel = dock.widget()
from eco.widgets.indicator_widgets import _IndicatorBase
leds = panel.findChildren(_IndicatorBase)
assert len(leds) == 1
assert leds[0].item is None # nothing to resolve against -> stays inert
finally:
board.stop()
# -- dock objectName uniqueness (Qt needs this for saveState/restoreState) --
def test_dock_object_name_unchanged_when_unique():
from eco.widgets.dashboard_qt import _dock_object_name
assert _dock_object_name("cam_west.intensity", []) == "cam_west.intensity"
def test_dock_object_name_dedupes_collisions():
from eco.widgets.dashboard_qt import _dock_object_name
existing = ["d1", "d1_2"]
assert _dock_object_name("d1", existing) == "d1_3"
def test_add_widget_gives_each_dock_a_unique_object_name():
app = _app()
board = Dashboard(auto_start=False)
board._build_window()
try:
item1 = _FakeDetector("d1", value=1.0)
item2 = _FakeDetector("d2", value=1.0)
w1 = create_indicator("LED", item1, title="Same title")
w2 = create_indicator("LED", item2, title="Same title")
dock1 = board.add_widget(w1, title="Same title")
dock2 = board.add_widget(w2, title="Same title")
assert dock1.objectName() != ""
assert dock2.objectName() != ""
assert dock1.objectName() != dock2.objectName()
finally:
board.stop()