Files
eco/tests/test_ipymagic.py
lemke_handClaude Sonnet 5 44e0d0df7a Two-tab (Components/Commands) confirm-gated picker; never auto-executes
Per user feedback on the ipymagic Qt-modal `...` fallback:

a) The picker window now has two tabs -- Components (the namespace tree,
   as before) and a new Commands tab (eco.logs recent/frequent commands),
   which previously only existed in the terminal Tab-completion overlay,
   not the Qt fallback.

b) A pick no longer executes the current cell at all: _AstTransformer.
   visit empties the cell's AST body and hands the expanded code to the
   next prompt via IPython's set_next_input (the same mechanism %edit/
   %load use) -- terminal and notebook/qtconsole alike. Verified live that
   the cell body never runs (no assignment happens) and the next prompt is
   pre-filled with the correct, directly re-executable expression.

c) In the picker, a single click only highlights a row now (tree, or a
   Recent/Bookmark combo choice) -- it never fires a pick by itself. Only
   Enter/Return, a double-click, or one shared Go button (below both tabs,
   confirms whichever tab is active) commits a selection.
   ComponentSelectorQt keeps its old click-to-select-immediately behavior
   by default (require_confirm=False) since select_component/scan_
   launcher_qt.py's existing picker dialog already depends on it --
   confirm-gating is opt-in, used only by the new PickerQtWindow.

d) Dropped the "Value" tree column (and _format_value): get_current_value()
   per row is a real, non-free call (EPICS or whatever) and the result is
   stale the instant it's drawn anyway.

pick_component_modal now returns (kind, text, obj) instead of (path, obj)
-- kind is "component" (text relative to root, via _full_path as before)
or "command" (text used verbatim, never run through _full_path).

Added tests/test_component_selector_qt.py for the new confirm-gating/
CommandSelectorQt behavior, and extended tests/test_ipymagic.py for the
3-tuple contract and the cancel-and-set-next-input behavior. Verified live
via Xvfb: tabs render, a plain click doesn't pick, Enter/double-click and
the shared Go button do, and the full ipymagic path (pick -> next input,
no execution) works end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:31:18 +02:00

348 lines
12 KiB
Python

"""eco.ipymagic -- the `...`-triggered inline component picker.
Qt itself (the actual picker window) is not exercised here -- these tests
monkeypatch eco.widgets.component_selector_qt.pick_component_modal (the one
function ipymagic._EllipsisPicker calls) and check the pure-AST behavior:
which `...` occurrences get replaced, in what order, with what, and which
are correctly left alone.
"""
import ast
import pytest
from eco import ipymagic
class _FakeEvents:
def register(self, *a, **k):
pass
class _FakeCompleter:
def __init__(self):
self.custom_matchers = []
class _FakeShell:
def __init__(self):
self.ast_transformers = []
self.Completer = _FakeCompleter()
@pytest.fixture(autouse=True)
def _reset_state():
ipymagic._state.clear()
yield
ipymagic._state.clear()
class _NamedRoot:
"""A pick_component_modal path is relative to root (see
ipymagic._full_path's docstring) -- these tests need a `root` with a
real `.name` for the resulting expanded code to come out right, not
a bare string (which has no `.name` and would fall back to a dummy
"root" prefix)."""
def __init__(self, name):
self.name = name
def _transform_source(source, root=None):
if root is None:
root = _NamedRoot("bernina")
tree = ast.parse(source)
picker = ipymagic._EllipsisPicker(root, "All", None, None)
new_tree = picker.visit(tree)
ast.fix_missing_locations(new_tree)
return ast.unparse(new_tree), picker.picked_any
def test_bare_ellipsis_expression_is_replaced_with_the_picked_path(monkeypatch):
monkeypatch.setattr(
"eco.widgets.component_selector_qt.pick_component_modal",
# relative to root, like the real picker
lambda root, **kw: ("component", "mono.energy", object()),
)
out, picked_any = _transform_source("(1 / ...).plot()")
assert out == "(1 / bernina.mono.energy).plot()"
assert picked_any is True
def test_two_markers_are_resolved_left_to_right_in_source_order(monkeypatch):
picks = iter(["mono.energy", "attenuator.transmission"])
seen_roots = []
def fake_pick(root, **kw):
seen_roots.append(root)
return "component", next(picks), object()
monkeypatch.setattr("eco.widgets.component_selector_qt.pick_component_modal", fake_pick)
root = _NamedRoot("bernina")
out, picked_any = _transform_source(
"cen = (left := ...) - (right := ...)", root=root
)
assert out == "cen = (left := bernina.mono.energy) - (right := bernina.attenuator.transmission)"
assert picked_any is True
assert seen_roots == [root, root]
def test_command_pick_is_used_verbatim_not_prefixed_with_root(monkeypatch):
# a "command" pick is already a full expression string -- unlike a
# "component" pick (a bare dotted path relative to root), it must not
# be run through _full_path.
monkeypatch.setattr(
"eco.widgets.component_selector_qt.pick_component_modal",
lambda root, **kw: ("command", "mono.mv(5)", None),
)
out, picked_any = _transform_source("...")
assert out == "mono.mv(5)"
assert picked_any is True
def test_subscript_slice_ellipsis_is_left_alone(monkeypatch):
def fail_if_called(root, **kw):
raise AssertionError("picker should not be invoked for arr[...]")
monkeypatch.setattr("eco.widgets.component_selector_qt.pick_component_modal", fail_if_called)
out, picked_any = _transform_source("arr[...].sum()")
assert out == "arr[...].sum()"
assert picked_any is False
def test_qt_modal_fallback_is_attempted_in_a_real_terminal_session(monkeypatch):
# a terminal session alone must no longer skip the Qt modal -- only the
# absence of a display does (see the next test).
monkeypatch.setattr(ipymagic, "_has_display", lambda: True)
monkeypatch.setattr(
"eco.widgets.component_selector_qt.pick_component_modal",
lambda root, **kw: ("component", "mono.energy", object()),
)
out, picked_any = _transform_source("(1 / ...).plot()")
assert out == "(1 / bernina.mono.energy).plot()"
assert picked_any is True
def test_qt_modal_fallback_is_skipped_without_a_display(monkeypatch, capsys):
monkeypatch.setattr(ipymagic, "_has_display", lambda: False)
def fail_if_called(root, **kw):
raise AssertionError("the Qt modal must not be opened with no display available")
monkeypatch.setattr("eco.widgets.component_selector_qt.pick_component_modal", fail_if_called)
out, picked_any = _transform_source("(1 / ...).plot()")
assert out == "(1 / ...).plot()"
assert picked_any is False
assert "Tab" in capsys.readouterr().out
def test_cancelled_pick_leaves_the_literal_ellipsis_in_place(monkeypatch, capsys):
monkeypatch.setattr(
"eco.widgets.component_selector_qt.pick_component_modal",
lambda root, **kw: (None, None, None),
)
out, picked_any = _transform_source("(1 / ...).plot()")
assert out == "(1 / ...).plot()"
assert picked_any is False
assert "cancelled" in capsys.readouterr().out
def test_transform_is_a_noop_when_start_has_not_been_called():
# ipymagic._state is empty (autouse fixture clears it) -- _transform
# itself (not just the picker) must not try to pick anything.
tree = ast.parse("(1 / ...).plot()")
out = ast.unparse(ipymagic._ast_transformer.visit(tree))
assert out == "(1 / ...).plot()"
def test_ast_transformer_cancels_execution_and_fills_next_input(monkeypatch):
# _EllipsisPicker alone (exercised above via _transform_source) only
# checks *what* gets spliced in -- this checks _AstTransformer.visit's
# own behavior once something was picked: the cell must not actually
# run (empty body), and the expanded code must go to the next prompt
# via set_next_input rather than being printed-then-executed.
ipymagic._state["root"] = _NamedRoot("bernina")
ipymagic._state["kind_filter"] = "All"
ipymagic._state["bookmarks"] = None
ipymagic._state["recent"] = None
monkeypatch.setattr(
"eco.widgets.component_selector_qt.pick_component_modal",
lambda root, **kw: ("component", "mono.energy", object()),
)
next_inputs = []
class _FakeIp:
def set_next_input(self, text, replace=False):
next_inputs.append((text, replace))
monkeypatch.setattr("IPython.get_ipython", lambda: _FakeIp())
tree = ast.parse("(1 / ...).plot()")
new_tree = ipymagic._ast_transformer.visit(tree)
assert new_tree.body == []
assert next_inputs == [("(1 / bernina.mono.energy).plot()", True)]
def test_start_registers_the_transformer_and_matchers_once(monkeypatch):
shell = _FakeShell()
monkeypatch.setattr("IPython.get_ipython", lambda: shell)
ipymagic.start("bernina")
ipymagic.start("bernina") # idempotent -- must not install a second hook
assert shell.ast_transformers == [ipymagic._ast_transformer]
assert shell.Completer.custom_matchers == [
ipymagic._component_matcher,
ipymagic._command_matcher,
]
assert ipymagic._state["root"] == "bernina"
def test_start_raises_without_an_ipython_session(monkeypatch):
monkeypatch.setattr("IPython.get_ipython", lambda: None)
with pytest.raises(RuntimeError):
ipymagic.start("bernina")
def test_stop_makes_the_transform_a_noop_again(monkeypatch):
shell = _FakeShell()
monkeypatch.setattr("IPython.get_ipython", lambda: shell)
ipymagic.start("bernina")
ipymagic.stop()
tree = ast.parse("(1 / ...).plot()")
out = ast.unparse(ipymagic._ast_transformer.visit(tree))
assert out == "(1 / ...).plot()"
# -- Tab-completion overlay (_component_matcher / _command_matcher) --------
class _Ctx:
"""Just enough of CompletionContext for these matchers -- both only
ever read `.token`."""
def __init__(self, token):
self.token = token
class _FakeRoot:
name = "bernina"
def _texts(result):
return [c.text for c in result["completions"]]
def _types(result):
return [c.type for c in result["completions"]]
def test_component_matcher_returns_nothing_when_not_started():
assert ipymagic._component_matcher(_Ctx("...")) == {"completions": []}
def test_component_matcher_ignores_tokens_without_the_trigger(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
assert ipymagic._component_matcher(_Ctx("bernina")) == {"completions": []}
def test_component_matcher_defers_to_command_matcher_for_the_history_trigger(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
assert ipymagic._component_matcher(_Ctx("...h")) == {"completions": []}
def _fake_node(name, children=()):
return type("N", (), {"name": name, "children": list(children)})()
def _fake_tree_relative_to_bernina():
# build_tree's own node names are relative to root (see _full_path's
# docstring) -- root's own name comes back "" (Alias.get_full_name
# stops at base), children are unprefixed ("mono.energy", not
# "bernina.mono.energy").
return _fake_node(
"",
children=[
_fake_node("mono.energy"),
_fake_node("attenuator.transmission"),
],
)
def test_component_matcher_sections_are_recent_then_recommended_then_namespace(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
monkeypatch.setattr(
"eco.elements.recent.RecentComponents.all", lambda self: ["bernina.slit1.width"]
)
monkeypatch.setattr(
"eco.elements.recent.FrequencyCounts.top",
lambda self, n: ["bernina.slit1.width", "bernina.mono.energy"],
)
monkeypatch.setattr(
"eco.widgets.component_selector.build_tree",
lambda root: _fake_tree_relative_to_bernina(),
)
result = ipymagic._component_matcher(_Ctx("..."))
# bernina.slit1.width: recent, wins over also being "recommended".
# bernina.mono.energy: recommended (already absolute -- RecentComponents/
# FrequencyCounts paths come from Alias.get_full_name() with no base,
# unlike build_tree's root-relative ones) -- also excluded from the
# namespace section below since it's already shown once.
# bernina.attenuator.transmission: plain namespace, root-prefixed since
# build_tree gave back the relative "attenuator.transmission".
assert _texts(result) == [
"bernina.slit1.width",
"bernina.mono.energy",
"bernina.attenuator.transmission",
]
assert _types(result) == ["recent", "recommended", "namespace"]
assert result["suppress"] is True
def test_component_matcher_query_narrows_namespace_but_not_recent(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
monkeypatch.setattr(
"eco.elements.recent.RecentComponents.all", lambda self: ["bernina.slit1.width"]
)
monkeypatch.setattr("eco.elements.recent.FrequencyCounts.top", lambda self, n: [])
monkeypatch.setattr(
"eco.widgets.component_selector.build_tree",
lambda root: _fake_tree_relative_to_bernina(),
)
result = ipymagic._component_matcher(_Ctx("...ene"))
# "slit1.width" doesn't match "ene" but stays -- Recent is unfiltered.
# Of the namespace section, only "bernina.mono.energy" (via "ene") matches.
assert _texts(result) == ["bernina.slit1.width", "bernina.mono.energy"]
def test_command_matcher_returns_nothing_when_not_started():
assert ipymagic._command_matcher(_Ctx("...h")) == {"completions": []}
def test_command_matcher_ignores_the_plain_component_trigger(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
assert ipymagic._command_matcher(_Ctx("...")) == {"completions": []}
def test_command_matcher_has_recent_and_recommended_but_no_namespace_section(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
monkeypatch.setattr("eco.logs.recent_commands", lambda n: ["mono.mv(5)"])
monkeypatch.setattr(
"eco.logs.frequent_commands", lambda n: ["daq.compare_channels()", "mono.mv(5)"]
)
result = ipymagic._command_matcher(_Ctx("...h"))
assert _texts(result) == ["mono.mv(5)", "daq.compare_channels()"]
assert _types(result) == ["recent", "recommended"]