Files
EvoScientist/tests/test_tui_widgets.py
T
Icy Fish 649f2ca121 fix: resolve TAB cursor disappearance and up/down double-handling (#58)
- Add priority binding for TAB to intercept before Textual's focus_next
- Remove duplicate up/down handling in on_key (now handled by priority
  bindings from PR #76)
- Update tests to use cmd_manager.list_commands() instead of removed
  _TUI_SLASH_COMMANDS

Closes #57

Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:28:34 +00:00

837 lines
31 KiB
Python

"""Unit tests for TUI widgets.
Tests widget construction, state transitions, and public APIs
without requiring a running Textual app (no Textual pilot needed).
"""
from __future__ import annotations
import importlib
import unittest
# ---------------------------------------------------------------------------
# Textual might not be installed — skip entire module if missing
# ---------------------------------------------------------------------------
_has_textual = importlib.util.find_spec("textual") is not None
@unittest.skipUnless(_has_textual, "textual not installed")
class TestLoadingWidget(unittest.TestCase):
"""LoadingWidget construction and attributes."""
def test_construction(self):
from EvoScientist.cli.widgets.loading_widget import LoadingWidget
w = LoadingWidget()
assert w._frame == 0
assert w._elapsed == 0.0
assert w._timer_handle is None
def test_spinner_frames_not_empty(self):
from EvoScientist.cli.widgets.loading_widget import _SPINNER_FRAMES
assert len(_SPINNER_FRAMES) > 0
@unittest.skipUnless(_has_textual, "textual not installed")
class TestThinkingWidget(unittest.TestCase):
"""ThinkingWidget construction, append, finalize."""
def test_construction_visible(self):
from EvoScientist.cli.widgets.thinking_widget import ThinkingWidget
w = ThinkingWidget(show_thinking=True)
assert w._is_active is True
assert w._content == ""
assert w.display is True
def test_construction_hidden(self):
from EvoScientist.cli.widgets.thinking_widget import ThinkingWidget
w = ThinkingWidget(show_thinking=False)
assert w.display is False
def test_append_text_accumulates(self):
from EvoScientist.cli.widgets.thinking_widget import ThinkingWidget
w = ThinkingWidget(show_thinking=True)
w._content = "" # direct access for unit test
# Simulate append (without DOM)
w._content += "hello "
w._content += "world"
assert w._content == "hello world"
def test_finalize_sets_inactive(self):
from EvoScientist.cli.widgets.thinking_widget import ThinkingWidget
w = ThinkingWidget(show_thinking=True)
w._is_active = True
# Simulate finalize without DOM
w._is_active = False
assert w._is_active is False
@unittest.skipUnless(_has_textual, "textual not installed")
class TestAssistantMessage(unittest.TestCase):
"""AssistantMessage construction."""
def test_empty_construction(self):
from EvoScientist.cli.widgets.assistant_message import AssistantMessage
w = AssistantMessage()
assert w._content == ""
def test_initial_content(self):
from EvoScientist.cli.widgets.assistant_message import AssistantMessage
w = AssistantMessage("hello world")
assert w._content == "hello world"
@unittest.skipUnless(_has_textual, "textual not installed")
class TestToolCallWidget(unittest.TestCase):
"""ToolCallWidget construction and state transitions."""
def test_construction(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("read_file", {"path": "/foo.py"}, "abc-123")
assert w._tool_name == "read_file"
assert w._tool_args == {"path": "/foo.py"}
assert w._tool_id == "abc-123"
assert w._status == "running"
def test_tool_id_property(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("grep", {"pattern": "foo"}, "xyz")
assert w.tool_id == "xyz"
assert w.tool_name == "grep"
def test_status_transitions(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("execute", {"command": "ls"})
assert w._status == "running"
# Direct state change for unit test (no DOM)
w._status = "success"
assert w._status == "success"
w._status = "error"
assert w._status == "error"
w._status = "interrupted"
assert w._status == "interrupted"
def test_result_summary_truncation(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("execute", {})
w._result_content = "a" * 100
summary = w._result_summary()
assert len(summary) <= 61 # 57 + "…"
def test_result_summary_empty(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("execute", {})
w._result_content = ""
assert w._result_summary() == "done"
def test_should_collapse_short(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("read_file", {})
w._result_content = "line1\nline2\nline3"
assert w._should_collapse() is False
def test_should_collapse_long(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("read_file", {})
w._result_content = "\n".join(f"line{i}" for i in range(20))
assert w._should_collapse() is True
@unittest.skipUnless(_has_textual, "textual not installed")
class TestSubAgentWidget(unittest.TestCase):
"""SubAgentWidget construction and name display."""
def test_construction(self):
from EvoScientist.cli.widgets.subagent_widget import SubAgentWidget
w = SubAgentWidget("research-agent", "Search literature")
assert w._sa_name == "research-agent"
assert w._description == "Search literature"
assert w._is_active is True
assert w._tool_count == 0
def test_sa_name_property(self):
from EvoScientist.cli.widgets.subagent_widget import SubAgentWidget
w = SubAgentWidget("code-agent")
assert w.sa_name == "code-agent"
def test_display_name_with_description(self):
from EvoScientist.cli.widgets.subagent_widget import SubAgentWidget
w = SubAgentWidget("research-agent", "Search for relevant papers")
name = w._display_name()
assert "Cooking with research-agent" in name
assert "Search for relevant papers" in name
def test_display_name_truncation(self):
from EvoScientist.cli.widgets.subagent_widget import SubAgentWidget
long_desc = "A" * 100
w = SubAgentWidget("agent", long_desc)
name = w._display_name()
assert len(name) < 100 # Should be truncated
def test_finalize(self):
from EvoScientist.cli.widgets.subagent_widget import SubAgentWidget
w = SubAgentWidget("agent")
w._is_active = True
w._is_active = False # Simulate finalize
assert w._is_active is False
def test_update_name(self):
from EvoScientist.cli.widgets.subagent_widget import SubAgentWidget
w = SubAgentWidget("sub-agent")
assert w._sa_name == "sub-agent"
assert w._description == ""
# Simulate name resolution
w.update_name("planner-agent", "Plan the experiment")
assert w._sa_name == "planner-agent"
assert w._description == "Plan the experiment"
assert "planner-agent" in w._display_name()
assert "Plan the experiment" in w._display_name()
def test_update_name_preserves_description(self):
from EvoScientist.cli.widgets.subagent_widget import SubAgentWidget
w = SubAgentWidget("sub-agent", "existing desc")
w.update_name("research-agent")
assert w._sa_name == "research-agent"
# Empty description should not overwrite existing
assert w._description == "existing desc"
def test_update_name_overwrites_description(self):
from EvoScientist.cli.widgets.subagent_widget import SubAgentWidget
w = SubAgentWidget("sub-agent", "old desc")
w.update_name("code-agent", "new desc")
assert w._description == "new desc"
def test_tool_widgets_dict_keyed_by_id(self):
"""_tool_widgets dict should be keyed by tool_id for dedup."""
from EvoScientist.cli.widgets.subagent_widget import SubAgentWidget
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
sa = SubAgentWidget("research-agent")
# Simulate pre-populating a tool widget (as add_tool_call would)
tw = ToolCallWidget("tavily_search", {"query": ""}, "id-1")
sa._tool_widgets["id-1"] = tw
# Verify lookup works
assert "id-1" in sa._tool_widgets
assert sa._tool_widgets["id-1"] is tw
@unittest.skipUnless(_has_textual, "textual not installed")
class TestTodoWidget(unittest.TestCase):
"""TodoWidget construction."""
def test_construction_empty(self):
from EvoScientist.cli.widgets.todo_widget import TodoWidget
w = TodoWidget()
assert w._items == []
def test_construction_with_items(self):
from EvoScientist.cli.widgets.todo_widget import TodoWidget
items = [
{"content": "Search papers", "status": "done"},
{"content": "Analyze data", "status": "active"},
{"content": "Write report", "status": "todo"},
]
w = TodoWidget(items)
assert len(w._items) == 3
def test_update_items(self):
from EvoScientist.cli.widgets.todo_widget import TodoWidget
w = TodoWidget()
items = [{"content": "task1", "status": "todo"}]
w._items = items # Direct set for unit test
assert w._items == items
@unittest.skipUnless(_has_textual, "textual not installed")
class TestUserMessage(unittest.TestCase):
"""UserMessage construction."""
def test_construction(self):
from EvoScientist.cli.widgets.user_message import UserMessage
w = UserMessage("hello world")
# Should create without error
assert w is not None
@unittest.skipUnless(_has_textual, "textual not installed")
class TestSystemMessage(unittest.TestCase):
"""SystemMessage construction."""
def test_construction_default_style(self):
from EvoScientist.cli.widgets.system_message import SystemMessage
w = SystemMessage("info text")
assert w is not None
def test_construction_custom_style(self):
from EvoScientist.cli.widgets.system_message import SystemMessage
w = SystemMessage("error!", msg_style="red")
assert w is not None
@unittest.skipUnless(_has_textual, "textual not installed")
class TestIsFinalResponse(unittest.TestCase):
"""Test _is_final_response helper."""
def test_empty_state_is_final(self):
from EvoScientist.cli.tui_interactive import _is_final_response
from EvoScientist.stream.state import StreamState
state = StreamState()
assert _is_final_response(state) is True
def test_pending_tools_not_final(self):
from EvoScientist.cli.tui_interactive import _is_final_response
from EvoScientist.stream.state import StreamState
state = StreamState()
state.tool_calls = [{"name": "read_file", "args": {}}]
state.tool_results = [] # No results yet
assert _is_final_response(state) is False
def test_all_tools_done_is_final(self):
from EvoScientist.cli.tui_interactive import _is_final_response
from EvoScientist.stream.state import StreamState
state = StreamState()
state.tool_calls = [{"name": "read_file", "args": {}}]
state.tool_results = [{"name": "read_file", "content": "[OK]"}]
assert _is_final_response(state) is True
def test_active_subagent_not_final(self):
from EvoScientist.cli.tui_interactive import _is_final_response
from EvoScientist.stream.state import StreamState, SubAgentState
state = StreamState()
sa = SubAgentState("research-agent")
sa.is_active = True
state.subagents = [sa]
assert _is_final_response(state) is False
def test_completed_subagent_is_final(self):
from EvoScientist.cli.tui_interactive import _is_final_response
from EvoScientist.stream.state import StreamState, SubAgentState
state = StreamState()
sa = SubAgentState("research-agent")
sa.is_active = False
state.subagents = [sa]
assert _is_final_response(state) is True
def test_internal_tools_ignored(self):
from EvoScientist.cli.tui_interactive import _is_final_response
from EvoScientist.stream.state import StreamState
state = StreamState()
state.tool_calls = [{"name": "ExtractedMemory", "args": {}}]
# No result for internal tool -- should still be considered final
assert _is_final_response(state) is True
def test_processing_not_final(self):
from EvoScientist.cli.tui_interactive import _is_final_response
from EvoScientist.stream.state import StreamState
state = StreamState()
state.is_processing = True
assert _is_final_response(state) is False
@unittest.skipUnless(_has_textual, "textual not installed")
class TestToolCallWidgetIcons(unittest.TestCase):
"""ToolCallWidget uses correct status icons (✓/✗/●)."""
def test_success_icon(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("read_file", {"path": "/f"}, "id1")
# After success, status should be "success"
w._status = "success"
assert w._status == "success"
def test_error_icon(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("execute", {"command": "bad"}, "id2")
w._status = "error"
assert w._status == "error"
def test_running_icon(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("grep", {}, "id3")
assert w._status == "running"
def test_interrupted_icon(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("execute", {"command": "long"}, "id4")
w._status = "interrupted"
assert w._status == "interrupted"
@unittest.skipUnless(_has_textual, "textual not installed")
class TestResponseStripping(unittest.TestCase):
"""Response text trailing '...' should be stripped."""
def test_strip_trailing_dots(self):
text = "Hello world\n..."
clean = text.strip()
while clean.endswith("\n...") or clean.rstrip() == "...":
clean = clean.rstrip().removesuffix("...").rstrip()
assert clean == "Hello world"
def test_no_strip_normal_text(self):
text = "Hello world"
clean = text.strip()
while clean.endswith("\n...") or clean.rstrip() == "...":
clean = clean.rstrip().removesuffix("...").rstrip()
assert clean == "Hello world"
def test_strip_standalone_dots(self):
text = "..."
clean = text.strip()
while clean.endswith("\n...") or clean.rstrip() == "...":
clean = clean.rstrip().removesuffix("...").rstrip()
assert clean == ""
@unittest.skipUnless(_has_textual, "textual not installed")
class TestWidgetImports(unittest.TestCase):
"""Verify all widgets are importable from the package."""
def test_all_imports(self):
from EvoScientist.cli.widgets import (
AssistantMessage,
LoadingWidget,
SubAgentWidget,
SystemMessage,
ThinkingWidget,
TodoWidget,
ToolCallWidget,
UserMessage,
)
# All should be classes
for cls in (
LoadingWidget,
ThinkingWidget,
AssistantMessage,
ToolCallWidget,
SubAgentWidget,
TodoWidget,
UserMessage,
SystemMessage,
):
assert isinstance(cls, type), f"{cls} is not a class"
class TestClipboardPaste(unittest.TestCase):
"""Test clipboard paste functionality."""
def test_get_clipboard_text_import(self):
"""get_clipboard_text should be importable."""
from EvoScientist.cli.clipboard import get_clipboard_text
assert callable(get_clipboard_text)
def test_paste_native_import(self):
"""_paste_native should be importable."""
from EvoScientist.cli.clipboard import _paste_native
assert callable(_paste_native)
def test_paste_native_returns_string_or_none(self):
"""_paste_native should return str or None."""
from EvoScientist.cli.clipboard import _paste_native
result = _paste_native()
assert result is None or isinstance(result, str)
def test_get_clipboard_text_with_pyperclip_mock(self):
"""get_clipboard_text should use pyperclip when available."""
from unittest.mock import MagicMock, patch
mock_pyperclip = MagicMock()
mock_pyperclip.paste.return_value = "mocked text"
with patch.dict("sys.modules", {"pyperclip": mock_pyperclip}):
# Re-import to pick up the mock
import importlib
from EvoScientist.cli import clipboard
importlib.reload(clipboard)
result = clipboard.get_clipboard_text()
# pyperclip.paste was called
mock_pyperclip.paste.assert_called_once()
assert result == "mocked text"
def test_get_clipboard_text_fallback_to_native(self):
"""get_clipboard_text should fall back to native when pyperclip unavailable."""
from unittest.mock import patch
with patch.dict("sys.modules", {"pyperclip": None}):
import importlib
from EvoScientist.cli import clipboard
importlib.reload(clipboard)
# Should not raise, returns None or string
result = clipboard.get_clipboard_text()
assert result is None or isinstance(result, str)
@unittest.skipUnless(_has_textual, "textual not installed")
class TestCompletionLogic(unittest.TestCase):
"""Unit tests for slash-command completion and TAB-key handling.
The completion methods live on EvoTextualInteractiveApp but require a
running Textual pilot to instantiate normally. We use a lightweight
stub that reimplements only the state fields and wires query_one() to
return fake widget objects, letting us exercise the pure logic without
starting the full TUI.
"""
# ------------------------------------------------------------------
# Stub infrastructure
# ------------------------------------------------------------------
def _make_app(self, comp_items=None, comp_index=-1):
"""Return a stub app-like object with completion state."""
from rich.text import Text
# Fake Input widget -------------------------------------------------
class _FakeInput:
def __init__(self):
self.value = ""
self.cursor_position = 0
def focus(self):
self._focused = True
# Fake Static widget ------------------------------------------------
class _FakeStatic:
def __init__(self):
self.display = False
self._content = None
def update(self, content):
self._content = content
fake_input = _FakeInput()
fake_completions = _FakeStatic()
# Widget registry -----------------------------------------------
_widgets = {
("#prompt", None): fake_input,
("#completions", None): fake_completions,
}
from EvoScientist.commands import manager as cmd_manager
_slash_commands = cmd_manager.list_commands()
# Build stub --------------------------------------------------------
class _StubApp:
"""Minimal stub that shares the real completion method bodies."""
def __init__(self):
self._comp_items: list = list(comp_items or [])
self._comp_index: int = comp_index
# Expose fakes for assertions
self._fake_input = fake_input
self._fake_completions = fake_completions
self._SLASH_COMMANDS = _slash_commands
def query_one(self, selector, widget_type=None):
# Match by selector string; widget_type is ignored in stub
if "prompt" in selector:
return fake_input
if "completions" in selector:
return fake_completions
raise KeyError(f"Unknown selector: {selector!r}")
# ---- copy real method bodies verbatim ----
def action_tab_complete(self):
comp_widget = self.query_one("#completions")
if not (comp_widget.display and self._comp_items):
self.query_one("#prompt").focus()
return
self._comp_index = (self._comp_index + 1) % len(self._comp_items)
self._apply_selected_completion()
def _apply_selected_completion(self):
selected_cmd = self._comp_items[self._comp_index][0]
prompt = self.query_one("#prompt")
prompt.value = selected_cmd + " "
prompt.cursor_position = len(prompt.value)
self._render_completions()
def _hide_completions(self):
self._comp_items = []
self._comp_index = -1
comp_widget = self.query_one("#completions")
comp_widget.display = False
def _render_completions(self):
comp_widget = self.query_one("#completions")
comp_text = Text()
for i, (cmd, desc) in enumerate(self._comp_items):
if i == self._comp_index:
comp_text.append("\u25b8 ", style="bold")
comp_text.append(f"{cmd:<22}", style="bold")
comp_text.append(desc, style="bold")
else:
comp_text.append(" ", style="#888888")
comp_text.append(f"{cmd:<22}", style="#888888")
comp_text.append(desc, style="#888888")
if i < len(self._comp_items) - 1:
comp_text.append("\n")
comp_widget.update(comp_text)
def on_input_changed(self, text: str):
"""Simplified version matching the real on_input_changed logic."""
comp_widget = self.query_one("#completions")
if text.startswith("/"):
prefix = text.lower()
matches = [
(cmd, desc)
for cmd, desc in self._SLASH_COMMANDS
if cmd.startswith(prefix)
]
if len(matches) == 1 and matches[0][0] == prefix:
self._hide_completions()
return
if matches:
self._comp_items = matches
self._comp_index = -1
self._render_completions()
comp_widget.display = True
return
self._hide_completions()
def on_key(self, key: str):
"""Simplified version matching the real on_key logic.
Up/down are handled by priority bindings, not on_key.
Only enter needs on_key handling.
"""
comp_widget = self.query_one("#completions")
if not (comp_widget.display and self._comp_items):
return False # did nothing
if key == "enter" and self._comp_index >= 0:
self._hide_completions()
return True
return False
return _StubApp()
# ------------------------------------------------------------------
# action_tab_complete
# ------------------------------------------------------------------
def test_tab_complete_no_completions_refocuses_prompt(self):
"""TAB with no active completions should refocus the prompt."""
app = self._make_app(comp_items=[])
app._fake_completions.display = False
app.action_tab_complete()
assert getattr(app._fake_input, "_focused", False) is True
def test_tab_complete_cycles_forward(self):
"""TAB with completions visible should advance the selection index."""
items = [("/resume", "desc1"), ("/run", "desc2"), ("/reset", "desc3")]
app = self._make_app(comp_items=items, comp_index=-1)
app._fake_completions.display = True
app.action_tab_complete()
assert app._comp_index == 0
assert app._fake_input.value == "/resume "
def test_tab_complete_wraps_around(self):
"""TAB past the last item should wrap back to index 0."""
items = [("/resume", "d1"), ("/run", "d2")]
app = self._make_app(comp_items=items, comp_index=1) # last item
app._fake_completions.display = True
app.action_tab_complete()
assert app._comp_index == 0
assert app._fake_input.value == "/resume "
def test_tab_complete_updates_cursor_position(self):
"""TAB should position the cursor at the end of the completed text."""
items = [("/skills", "List installed skills")]
app = self._make_app(comp_items=items, comp_index=-1)
app._fake_completions.display = True
app.action_tab_complete()
expected = "/skills "
assert app._fake_input.value == expected
assert app._fake_input.cursor_position == len(expected)
# ------------------------------------------------------------------
# _apply_selected_completion
# ------------------------------------------------------------------
def test_apply_selected_completion(self):
items = [("/new", "Start new"), ("/next", "Next")]
app = self._make_app(comp_items=items, comp_index=1)
app._apply_selected_completion()
assert app._fake_input.value == "/next "
assert app._fake_input.cursor_position == len("/next ")
# ------------------------------------------------------------------
# _hide_completions
# ------------------------------------------------------------------
def test_hide_completions_clears_state(self):
items = [("/resume", "d")]
app = self._make_app(comp_items=items, comp_index=0)
app._fake_completions.display = True
app._hide_completions()
assert app._comp_items == []
assert app._comp_index == -1
assert app._fake_completions.display is False
# ------------------------------------------------------------------
# _render_completions
# ------------------------------------------------------------------
def test_render_completions_produces_text(self):
"""_render_completions should call update() with a rich Text object."""
from rich.text import Text
items = [("/resume", "Resume session"), ("/run", "Run")]
app = self._make_app(comp_items=items, comp_index=0)
app._render_completions()
result = app._fake_completions._content
assert isinstance(result, Text)
plain = result.plain
assert "/resume" in plain
assert "/run" in plain
def test_render_completions_highlights_selected(self):
"""The selected item should have a bold arrow marker."""
from rich.text import Text
items = [("/help", "Help"), ("/hitl", "HITL")]
app = self._make_app(comp_items=items, comp_index=1)
app._render_completions()
result: Text = app._fake_completions._content
# Collect spans for bold segments
bold_spans = [
result.plain[s.start : s.end]
for s in result._spans
if "bold" in str(s.style)
]
# The arrow marker "▸" should appear in a bold span
arrow_in_bold = any("▸" in seg for seg in bold_spans)
assert arrow_in_bold, f"No bold arrow found. Spans: {bold_spans}"
# ------------------------------------------------------------------
# on_input_changed
# ------------------------------------------------------------------
def test_input_changed_slash_shows_completions(self):
"""/re prefix should show matching commands."""
app = self._make_app()
app.on_input_changed("/re")
assert app._fake_completions.display is True
assert len(app._comp_items) > 0
assert all(cmd.startswith("/re") for cmd, _ in app._comp_items)
def test_input_changed_exact_match_hides_completions(self):
"""An exact match for a command should hide completions."""
app = self._make_app()
# /help is the only command starting with /help
app.on_input_changed("/help")
assert app._fake_completions.display is False
def test_input_changed_non_slash_hides_completions(self):
"""Regular text (no leading slash) should hide completions."""
items = [("/resume", "d")]
app = self._make_app(comp_items=items)
app._fake_completions.display = True
app.on_input_changed("hello world")
assert app._fake_completions.display is False
def test_input_changed_no_match_hides_completions(self):
"""A /prefix that matches nothing should hide completions."""
app = self._make_app()
app._fake_completions.display = True
app.on_input_changed("/zzznomatch")
assert app._fake_completions.display is False
# ------------------------------------------------------------------
# on_key (enter only — up/down handled by priority bindings)
# ------------------------------------------------------------------
def test_on_key_enter_hides_completions_when_selected(self):
items = [("/resume", "d1")]
app = self._make_app(comp_items=items, comp_index=0)
app._fake_completions.display = True
handled = app.on_key("enter")
assert handled is True
assert app._fake_completions.display is False
assert app._comp_items == []
def test_on_key_enter_ignored_when_nothing_selected(self):
"""Enter with comp_index == -1 should not hide completions."""
items = [("/resume", "d1")]
app = self._make_app(comp_items=items, comp_index=-1)
app._fake_completions.display = True
handled = app.on_key("enter")
assert handled is False
assert app._fake_completions.display is True # unchanged
def test_on_key_noop_when_completions_hidden(self):
"""Key events should be ignored when completions are not visible."""
items = [("/resume", "d1")]
app = self._make_app(comp_items=items, comp_index=0)
app._fake_completions.display = False # hidden
handled = app.on_key("down")
assert handled is False
assert app._comp_index == 0 # unchanged
if __name__ == "__main__":
unittest.main()