mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 16:11:27 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVD58dnQCRQSxSJLj5enEZ
352 lines
12 KiB
Python
352 lines
12 KiB
Python
from unittest import mock
|
|
|
|
import pytest
|
|
from qtpy.QtCore import QPoint, Qt
|
|
from qtpy.QtWidgets import QMainWindow, QWidget
|
|
|
|
from bec_widgets.applications.main_app import BECMainApp
|
|
from bec_widgets.tests.client_mocks import mocked_client
|
|
from bec_widgets.widgets.utility.command_palette.command_palette import (
|
|
CommandPalette,
|
|
default_flavour,
|
|
)
|
|
from bec_widgets.widgets.utility.command_palette.palette_core import (
|
|
CommandPaletteController,
|
|
PaletteCommand,
|
|
fuzzy_match,
|
|
rich_title,
|
|
)
|
|
|
|
|
|
def _commands(calls: list | None = None):
|
|
calls = calls if calls is not None else []
|
|
|
|
def cmd(uid, title, category, **kwargs):
|
|
return PaletteCommand(
|
|
uid=uid, title=title, category=category, callback=lambda: calls.append(uid), **kwargs
|
|
)
|
|
|
|
return [
|
|
cmd("view:docks", "Go to Dock Area", "navigate"),
|
|
cmd("app:theme", "Switch to Light Theme", "actions", keywords="appearance"),
|
|
cmd("widget:Waveform", "Waveform", "widgets"),
|
|
cmd("widget:MultiWaveform", "Multi Waveform", "widgets"),
|
|
cmd("widget:ScanControl", "Scan Control", "widgets"),
|
|
cmd("device:samx", "samx", "devices", subtitle="SimPositioner"),
|
|
cmd("device:bpm4i", "bpm4i", "devices", subtitle="SimMonitor"),
|
|
cmd("workspace:alignment", "alignment", "workspaces"),
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def controller(qtbot):
|
|
calls = []
|
|
ctrl = CommandPaletteController([lambda: _commands(calls)])
|
|
ctrl.calls = calls
|
|
ctrl.refresh()
|
|
yield ctrl
|
|
ctrl.deleteLater()
|
|
|
|
|
|
# ---------------------------------------------------------------- matching
|
|
|
|
|
|
def test_fuzzy_match_prefers_substring_and_word_starts():
|
|
exact = fuzzy_match("wave", "Waveform")
|
|
inner = fuzzy_match("wave", "Multi Waveform")
|
|
scattered = fuzzy_match("wf", "Waveform")
|
|
assert exact[1] == [0, 1, 2, 3]
|
|
assert inner[1] == [6, 7, 8, 9]
|
|
assert exact[0] > inner[0] > scattered[0]
|
|
assert fuzzy_match("sc", "Scan Control")[1] == [0, 1]
|
|
assert fuzzy_match("scc", "Scan Control")[1] == [0, 1, 5]
|
|
assert fuzzy_match("zz", "Waveform") is None
|
|
assert fuzzy_match("", "anything") == (0.0, [])
|
|
|
|
|
|
def test_rich_title_highlights_and_escapes():
|
|
text = rich_title("a<b", [0, 2], "#ff0000")
|
|
assert text == (
|
|
'<b><font color="#ff0000">a</font></b><<b><font color="#ff0000">b</font></b>'
|
|
)
|
|
assert rich_title("plain", [], "#000") == "plain"
|
|
|
|
|
|
# ---------------------------------------------------------------- controller
|
|
|
|
|
|
def test_empty_query_groups_by_category(controller):
|
|
sections = [row["section"] for row in controller.model.items]
|
|
assert sections[0] == "Go to"
|
|
assert "Widgets" in sections and "Devices" in sections and "Workspaces" in sections
|
|
assert controller.sectioned
|
|
assert controller.currentIndex == 0
|
|
|
|
|
|
def test_query_ranks_best_match_first(controller):
|
|
controller.set_query("wave")
|
|
titles = [cmd.title for cmd in controller.results]
|
|
assert titles[:2] == ["Waveform", "Multi Waveform"]
|
|
assert not controller.sectioned
|
|
assert controller.model.items[0]["positions"] == [0, 1, 2, 3]
|
|
|
|
|
|
def test_multi_token_and_keyword_search(controller):
|
|
controller.set_query("multi wave")
|
|
assert [cmd.uid for cmd in controller.results] == ["widget:MultiWaveform"]
|
|
controller.set_query("appearance")
|
|
assert [cmd.uid for cmd in controller.results] == ["app:theme"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text, scope, uids",
|
|
[
|
|
("@sam", "devices", ["device:samx"]),
|
|
("+scan", "widgets", ["widget:ScanControl"]),
|
|
("#", "workspaces", ["workspace:alignment"]),
|
|
(">dock", "actions", ["view:docks"]),
|
|
],
|
|
)
|
|
def test_prefix_selects_scope(controller, text, scope, uids):
|
|
controller.set_query(text)
|
|
assert controller.scope == scope
|
|
assert [cmd.uid for cmd in controller.results] == uids
|
|
|
|
|
|
def test_scope_cycling_and_clearing(controller):
|
|
controller.cycle_scope(1)
|
|
assert controller.scope == "actions"
|
|
assert {cmd.category for cmd in controller.results} == {"navigate", "actions"}
|
|
controller.cycle_scope(-1)
|
|
controller.cycle_scope(-1)
|
|
assert controller.scope == "workspaces"
|
|
controller.clear_scope()
|
|
assert controller.scope == "all"
|
|
|
|
|
|
def test_move_wraps_and_page_clamps(controller):
|
|
controller.set_query("wave")
|
|
assert controller.resultCount == 2
|
|
controller.move(1)
|
|
assert controller.currentIndex == 1
|
|
controller.move(1)
|
|
assert controller.currentIndex == 0
|
|
controller.move(-1)
|
|
assert controller.currentIndex == 1
|
|
controller.move_page(-8)
|
|
assert controller.currentIndex == 0
|
|
|
|
|
|
def test_empty_text(controller):
|
|
controller.set_query("@xyzzy")
|
|
assert controller.resultCount == 0
|
|
assert controller.currentIndex == -1
|
|
assert controller.empty_text() == "No matches for “xyzzy” in devices"
|
|
|
|
|
|
def test_execute_runs_command_and_remembers_it(qtbot, controller):
|
|
accepted = []
|
|
controller.accepted.connect(accepted.append)
|
|
controller.set_query("bpm")
|
|
controller.execute()
|
|
assert accepted == ["device:bpm4i"]
|
|
qtbot.waitUntil(lambda: controller.calls == ["device:bpm4i"], timeout=1000)
|
|
|
|
controller.reset()
|
|
controller.refresh()
|
|
first = controller.model.items[0]
|
|
assert (first["uid"], first["section"]) == ("device:bpm4i", "Recent")
|
|
|
|
# recently used commands win ties
|
|
controller.set_query("s")
|
|
assert controller.results[0].uid != "device:bpm4i"
|
|
controller.execute(-1)
|
|
controller.set_query("b")
|
|
assert controller.results[0].uid == "device:bpm4i"
|
|
|
|
|
|
def test_failing_source_is_skipped(qtbot):
|
|
def broken():
|
|
raise RuntimeError("boom")
|
|
|
|
ctrl = CommandPaletteController([broken, lambda: _commands()])
|
|
ctrl.refresh()
|
|
assert len(ctrl.commands) == len(_commands())
|
|
ctrl.deleteLater()
|
|
|
|
|
|
def test_default_flavour(monkeypatch):
|
|
monkeypatch.delenv("BEC_COMMAND_PALETTE", raising=False)
|
|
assert default_flavour() == "qwidget"
|
|
monkeypatch.setenv("BEC_COMMAND_PALETTE", "QML")
|
|
assert default_flavour() == "qml"
|
|
|
|
|
|
# ---------------------------------------------------------------- overlay, both renderers
|
|
|
|
|
|
@pytest.fixture(params=["qwidget", "qml"])
|
|
def palette_window(qtbot, request):
|
|
window = QMainWindow()
|
|
window.setCentralWidget(QWidget())
|
|
window.resize(900, 640)
|
|
calls = []
|
|
palette = CommandPalette(window, sources=[lambda: _commands(calls)], flavour=request.param)
|
|
palette.install_shortcuts()
|
|
palette.calls = calls
|
|
qtbot.addWidget(window)
|
|
window.show()
|
|
qtbot.waitExposed(window)
|
|
yield window, palette
|
|
palette.cleanup()
|
|
|
|
|
|
def _type(qtbot, palette, text):
|
|
if palette.flavour == "qml":
|
|
palette.card.view.setFocus()
|
|
qtbot.keyClicks(palette.card.view, text)
|
|
else:
|
|
qtbot.keyClicks(palette.card.input, text)
|
|
|
|
|
|
def _key(qtbot, palette, key):
|
|
target = palette.card.view if palette.flavour == "qml" else palette.card.input
|
|
qtbot.keyClick(target, key)
|
|
|
|
|
|
def test_overlay_opens_with_shortcut_and_runs_command(qtbot, palette_window):
|
|
window, palette = palette_window
|
|
window.activateWindow()
|
|
window.centralWidget().setFocus()
|
|
palette.toggle()
|
|
assert palette.is_open()
|
|
assert palette.geometry() == window.rect()
|
|
card = palette.card_geometry()
|
|
assert card.width() == 640 and card.top() >= 24
|
|
|
|
_type(qtbot, palette, "wave")
|
|
qtbot.waitUntil(lambda: palette.controller.query == "wave", timeout=2000)
|
|
_key(qtbot, palette, Qt.Key.Key_Down)
|
|
assert palette.controller.currentIndex == 1
|
|
_key(qtbot, palette, Qt.Key.Key_Return)
|
|
assert not palette.is_open()
|
|
qtbot.waitUntil(lambda: palette.calls == ["widget:MultiWaveform"], timeout=1000)
|
|
|
|
|
|
def test_overlay_tab_and_escape(qtbot, palette_window):
|
|
_window, palette = palette_window
|
|
palette.open_palette()
|
|
_key(qtbot, palette, Qt.Key.Key_Tab)
|
|
assert palette.controller.scope == "actions"
|
|
_key(qtbot, palette, Qt.Key.Key_Backspace)
|
|
assert palette.controller.scope == "all"
|
|
_key(qtbot, palette, Qt.Key.Key_Escape)
|
|
assert not palette.is_open()
|
|
|
|
|
|
def test_overlay_click_outside_closes(qtbot, palette_window):
|
|
_window, palette = palette_window
|
|
palette.open_palette("@")
|
|
assert palette.controller.scope == "devices"
|
|
qtbot.mouseClick(palette, Qt.MouseButton.LeftButton, pos=QPoint(5, 5))
|
|
assert not palette.is_open()
|
|
# reopening starts fresh
|
|
palette.open_palette()
|
|
assert palette.controller.scope == "all" and palette.controller.query == ""
|
|
|
|
|
|
def test_qwidget_card_follows_controller(qtbot, palette_window):
|
|
_window, palette = palette_window
|
|
if palette.flavour != "qwidget":
|
|
pytest.skip("QWidget card only")
|
|
palette.open_palette()
|
|
card = palette.card
|
|
palette.controller.set_query(">")
|
|
assert card.input.text() == ""
|
|
assert [c.isChecked() for c in card.chips.buttons()][1]
|
|
palette.controller.set_query("nothing-matches")
|
|
assert card.stack.currentWidget() is card.empty
|
|
assert "nothing-matches" in card.empty_title.text()
|
|
assert card.count_label.text() == ""
|
|
|
|
|
|
def test_qml_card_loads(qtbot, palette_window):
|
|
_window, palette = palette_window
|
|
if palette.flavour != "qml":
|
|
pytest.skip("QML card only")
|
|
palette.open_palette()
|
|
assert palette.card.view.errors() == []
|
|
assert palette.card.view.rootObject() is not None
|
|
|
|
|
|
# ---------------------------------------------------------------- main app integration
|
|
|
|
|
|
@pytest.fixture
|
|
def main_app(qtbot, mocked_client):
|
|
app = BECMainApp(client=mocked_client, anim_duration=1, show_examples=False)
|
|
qtbot.addWidget(app)
|
|
app.show()
|
|
qtbot.waitExposed(app)
|
|
yield app
|
|
|
|
|
|
def _by_uid(app):
|
|
app.command_palette.controller.refresh()
|
|
return {cmd.uid: cmd for cmd in app.command_palette.controller.commands}
|
|
|
|
|
|
def test_main_app_palette_menu_entry(main_app):
|
|
action = main_app._command_palette_action
|
|
assert [s.toString() for s in action.shortcuts()] == ["Ctrl+K", "Ctrl+Shift+P"]
|
|
view_action = next(a for a in main_app.menuBar().actions() if a.text() == "View")
|
|
assert action in view_action.menu().actions()
|
|
action.trigger()
|
|
assert main_app.command_palette.is_open()
|
|
|
|
|
|
def test_main_app_commands(main_app):
|
|
commands = _by_uid(main_app)
|
|
assert "view:DM" in commands
|
|
assert "widget:Waveform" in commands and "widget:ScanControl" in commands
|
|
assert "device:samx" in commands and commands["device:samx"].hint == "Move"
|
|
assert commands["device:bpm4i"].hint == "Plot"
|
|
assert "menu:View/Dark Theme" in commands
|
|
assert "workspace_action:save" in commands
|
|
# the palette does not list itself
|
|
assert not any("Command Palette" in cmd.title for cmd in commands.values())
|
|
|
|
|
|
def test_main_app_navigation_command(qtbot, main_app):
|
|
_by_uid(main_app)["view:DM"].callback()
|
|
assert main_app.stack.currentIndex() == main_app._view_index["DM"]
|
|
|
|
|
|
def test_main_app_widget_and_device_commands(qtbot, main_app):
|
|
dock_area = main_app.dock_area.dock_area
|
|
main_app.set_current("DM")
|
|
commands = _by_uid(main_app)
|
|
commands["widget:RingProgressBar"].callback()
|
|
assert main_app.stack.currentIndex() == main_app._view_index["Docks"]
|
|
assert any(type(w).__name__ == "RingProgressBar" for w in dock_area.widget_list())
|
|
|
|
commands["device:samx"].callback()
|
|
boxes = [w for w in dock_area.widget_list() if type(w).__name__ == "PositionerBox"]
|
|
assert boxes and boxes[0].device == "samx"
|
|
|
|
with mock.patch("bec_widgets.widgets.plots.waveform.waveform.Waveform.plot") as plot:
|
|
commands["device:bpm4i"].callback()
|
|
plot.assert_called_once_with(device_y="bpm4i")
|
|
|
|
|
|
def test_main_app_workspace_commands(qtbot, main_app):
|
|
dock_area = main_app.dock_area.dock_area
|
|
with mock.patch.object(dock_area, "list_profiles", return_value=["alignment"]):
|
|
commands = _by_uid(main_app)
|
|
with mock.patch.object(dock_area, "load_profile") as load:
|
|
commands["workspace:alignment"].callback()
|
|
load.assert_called_once_with("alignment")
|
|
locked = dock_area.workspace_is_locked
|
|
commands["workspace_action:lock"].callback()
|
|
assert dock_area.workspace_is_locked is (not locked)
|