Files
EvoScientist/tests/test_tui_widgets.py
T
houren Antony 76972449c7 feat(cli): multi-stage slash command completions with subcommand awareness (Phase 1 of #82) (#273)
* feat(cli): multi-stage slash command completions with subcommand awareness

Phase 1 of #82 — subcommand and argument awareness in completions.

- commands/base.py: add SubCommand dataclass and subcommands/category
  ClassVars to the Command ABC. Each SubCommand has name, description,
  and optional arguments.

- commands/manager.py: add get_subcommands() and list_subcommands()
  methods to expose subcommand metadata for completion rendering.

- commands/implementation/mcp.py: declare 6 subcommands (list, config,
  add, edit, remove, install).

- commands/implementation/model_fallback.py: declare 6 subcommands
  (list, add, remove, clear, save, help).

- commands/implementation/channel.py: declare 2 subcommands
  (status, stop).

- cli/tui_interactive.py: rewrite on_text_area_changed slash-completion
  branch. When the user types a command name + trailing space and the
  command has subcommands, show subcommand completions instead of hiding
  the popup. Filter subcommands by typed prefix in multi-token input.

- commands/implementation/general.py: /help now lists subcommands
  below each command that declares them.

Tests: 8 new tests covering SubCommand creation, CommandManager
subcommand lookup, and cross-command verification.
2293 passed baseline, no regressions.

* fix: subcommand completion preserves prefix + prompt_toolkit + tests

- _apply_selected_completion: preserve '/mcp ' prefix when completing
  subcommands via _comp_is_subcommand flag
- SlashCommandCompleter (Rich CLI): add subcommand completion support
- Fix trailing-space bug: rstrip prefix before top-level matching
- test_tui_widgets.py: update stub on_input_changed to match multi-stage
  logic; add 5 new subcommand tests
- test_command_manager.py: 8 tests for SubCommand + CommandManager

28 passed, 0 failed.

* style: ruff format tui_interactive.py + test_tui_widgets.py

* style: fix RUF012 ClassVar annotation on subcommands lists

* fix: sync test stub, add len>=3 guard, remove exact-match hide

- Sync test stub on_input_changed with real TUI code (remove exact-match
  hide for subcommands, add len(parts)>=3 guard)
- Update test_input_changed_exact_subcommand_hides -> shows_confirmation
- Add test_input_changed_three_parts_hides
- Remove unused category ClassVar (din0s: what is this for)

* refactor(commands): extract shared completion engine

Per din0s feedback: one shared completion engine (commands/_completion_engine.py)
that parses text + cursor once, returns structured CompletionCandidate objects
with replace_start/replace_end ranges.

- SlashCommandCompleter (Rich CLI): thin adapter, delegates to engine
- on_text_area_changed (TUI): thin adapter, delegates to engine
- _apply_selected_completion: uses candidate.replace_start/replace_end instead
  of _comp_is_subcommand flag
- Tests: engine tested directly (10 new tests), stub methods updated

29 passed, 0 failed.

* style: ruff format

* fix: preserve text after cursor when applying completion

CodeRabbit: replace_start only cuts from start to cursor,
dropping any suffix after the cursor. Use replace_start + replace_end
to correctly splice the replacement while preserving trailing text.

* fix(cli): repair slash-command completion (TUI crash, subcommand bugs, sort)

Apology + context: the previous push shipped a TUI-breaking change
(the new shared engine assumed ``event.text_area.cursor_position``
existed, but ``ChatTextArea`` / ``Changed`` don't expose it). User
caught the crash on ``/``; fixing that surfaced two more bugs in
the engine that din0s had already flagged. This commit addresses
all of them and drops a piece of dead stub code.

## Bug fixes

1. **TUI crash on ``/``** (``tui_interactive.py:2335``)
   ``event.cursor_position`` doesn't exist on the ``Changed`` event,
   and ``ChatTextArea`` (Textual ``TextArea`` subclass) doesn't expose
   ``cursor_position`` either. Pass ``len(event.text_area.text)``
   instead — the user types at the end of the input in practice.

2. **Subcommand trailing-space duplication** (``_completion_engine.py``)
   Typing ``/mcp a `` + Tab produced ``/mcp aadd``. The engine
   included the trailing space in ``replace_end``; the TUI apply
   unconditionally appended ``" "``, producing double-space output.
   Fix: ``replace_end`` excludes the trailing space; the TUI apply
   checks ``current[replace_end:].startswith(" ")`` and skips the
   separator when the suffix already has one.

3. **Subcommand exact-match confirmation noise** (``_completion_engine.py``)
   Typing ``/mcp list`` + Tab re-inserted ``list`` and the popup
   kept showing the same subcommand. Add a guard mirroring the
   top-level exact-match rule: when the only subcommand match is
   the prefix itself (no trailing space), return ``empty``.

4. **Alphabetical sort dropped in CLI** (``cli/interactive.py``)
   The new completer iterated ``result.candidates`` in registration
   order. Re-add ``sorted(result.candidates, key=lambda c: c.text)``.
   Same sort added to the TUI for consistency.

## Cleanup

- Drop the dead ``on_input_changed`` method from the ``_StubApp``
  test stub (0 call sites) plus the unused ``_slash_commands`` /
  ``_subcommands`` locals that fed it. This addresses din0s's
  comment about the stub duplicating real TUI logic — the inlined
  copy is no longer needed since the real completer now routes
  through the shared engine.

## Tests

- ``test_engine_exact_subcommand_shows_confirmation`` → renamed to
  ``test_engine_exact_subcommand_hides`` to match new behavior.
- New: ``test_engine_subcommand_trailing_space_excludes_space_from_range``
  and ``test_engine_subcommand_trailing_space_apply_does_not_double_space``.
- All 97 tests in ``test_tui_widgets.py`` pass.
- ``ruff check`` / ``ruff format`` clean.
- Local TUI smoke: ``/`` (no crash, top-level popup), ``/mcp ``
  (subcommand popup), ``/mcp a `` + Tab → ``/mcp add ``.

Refs the din0s review comments on PR #273. CLI path tests and the
``category`` ClassVar follow-up are deferred to a separate PR (the
former is a test-suite addition; the latter is already absent from
``base.py`` on the current branch).

* fix: address remaining review items (help duplication, CLI tests, stub sync, docstrings)

- mcp.py: auto-generate help text from subcommands ClassVar (#1)
- tests/test_cli_completion.py: add 9 CLI completer tests (#2c)
- test_tui_widgets.py: sync _apply_selected_completion stub with real code (#4)
- mcp.py + interactive.py: add docstrings to key functions (#8)

* fix: hide completions on exact subcommand match regardless of trailing space

Remove the
ot has_trailing_space guard from the exact-subcommand
check.  Previously /mcp list  (with trailing space) would still
return candidates, causing Tab to oscillate between adding and removing
the trailing whitespace.  Now the engine hides whenever the subcommand
is an exact match, same as the top-level rule.

Added test_engine_exact_subcommand_with_trailing_space_hides to cover
the scenario din0s flagged.

* refactor: use StrEnum for CompletionResult.kind

Replace plain str with CompletionKind(StrEnum) for type safety.
Backward-compatible with existing string comparisons.

* fix: normalize @file completion tuples to CompletionCandidate

complete_file_mention() returns list[tuple[str, str]] but the TUI
rendering/apply code expects objects with .text/.description.
Wrap tuples in CompletionCandidate to prevent AttributeError crash.

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-06-13 17:06:23 +00:00

1369 lines
50 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
from unittest.mock import AsyncMock
# ---------------------------------------------------------------------------
# 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
from EvoScientist.cli.widgets.timed_status_widget import TimedStatusWidget
w = LoadingWidget()
assert isinstance(w, TimedStatusWidget)
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
def test_tick_advances_spinner_and_elapsed(self):
from EvoScientist.cli.widgets.loading_widget import LoadingWidget
w = LoadingWidget()
w._tick()
assert w._frame == 1
assert w._elapsed == 0.1
def test_cleanup_stops_timer_and_removes(self):
from EvoScientist.cli.widgets.loading_widget import LoadingWidget
class _Timer:
def __init__(self) -> None:
self.stopped = False
def stop(self) -> None:
self.stopped = True
w = LoadingWidget()
timer = _Timer()
w._timer_handle = timer
w.remove = AsyncMock()
from tests.conftest import run_async
run_async(w.cleanup())
assert timer.stopped is True
assert w._timer_handle is None
w.remove.assert_awaited_once()
@unittest.skipUnless(_has_textual, "textual not installed")
class TestCompactingWidget(unittest.TestCase):
"""CompactingWidget construction and cleanup."""
def test_construction(self):
from EvoScientist.cli.widgets.compacting_widget import CompactingWidget
w = CompactingWidget()
assert w._elapsed == 0.0
assert w._timer_handle is None
@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
class TestSummarizationStateMachine(unittest.TestCase):
"""Summary panel lifecycle decisions in the TUI event loop."""
def test_summary_continuation_events_do_not_finalize(self):
from EvoScientist.cli.tui_interactive import (
_should_finalize_active_summarization,
)
for event_type in ("summarization_start", "summarization", "usage_stats"):
assert _should_finalize_active_summarization(event_type) is False
def test_non_summary_events_finalize_active_summary(self):
from EvoScientist.cli.tui_interactive import (
_should_finalize_active_summarization,
)
for event_type in (
"thinking",
"text",
"tool_call",
"tool_result",
"tool_selection",
"subagent_start",
"subagent_tool_call",
"subagent_tool_result",
"subagent_end",
"ask_user",
"interrupt",
"done",
"error",
):
assert _should_finalize_active_summarization(event_type) is True
class TestStoppedResponseText(unittest.TestCase):
"""Stopped-response text normalization."""
def test_trims_before_appending_marker(self):
from EvoScientist.stream.display import build_stopped_response_text
current, final_text = build_stopped_response_text("partial answer \n")
assert current == "partial answer"
assert final_text == "partial answer\n[Stopped.]"
def test_does_not_duplicate_marker(self):
from EvoScientist.stream.display import build_stopped_response_text
current, final_text = build_stopped_response_text("partial\n[Stopped.]")
assert current == "partial\n[Stopped.]"
assert final_text == "partial\n[Stopped.]"
def test_strips_trailing_placeholder_ellipsis(self):
from EvoScientist.cli.tui_interactive import (
_strip_trailing_placeholder_ellipsis,
)
assert (
_strip_trailing_placeholder_ellipsis("final answer\n...") == "final answer"
)
assert _strip_trailing_placeholder_ellipsis("...") == ""
assert (
_strip_trailing_placeholder_ellipsis("final answer\n...\n...")
== "final answer"
)
@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_memory_tool_header_uses_result_inference(self):
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
w = ToolCallWidget("edit_file", {}, "mem-1")
w._result_content = "Successfully replaced 1 instance(s) of the string in '/memories/profile/USER_PROFILE.md'"
class _Header:
def __init__(self) -> None:
self.updated = None
def update(self, value) -> None:
self.updated = value
header = _Header()
w.query_one = lambda selector, cls=None: header # type: ignore[assignment]
w._status = "success"
w._render_header()
assert "Updating memory" in header.updated.plain
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
def test_complete_tool_routes_by_id_and_dedups(self):
"""Result delivery must use tool_call_id so concurrent same-name tools
don't leave orphans that get marked ``interrupted`` at finalize time.
Also, repeat deliveries must not inflate ``_completed_ids``.
"""
from EvoScientist.cli.widgets.subagent_widget import SubAgentWidget
from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget
class _FakeToolWidget(ToolCallWidget):
def set_success(self, content):
self._status = "success"
self._result_content = content
def set_error(self, content):
self._status = "error"
self._result_content = content
sa = SubAgentWidget("code-agent")
sa._update_visibility = lambda: None # skip mount-dependent render
tw_a = _FakeToolWidget("execute", {"cmd": "a"}, "id_A")
tw_b = _FakeToolWidget("execute", {"cmd": "b"}, "id_B")
sa._tool_widgets["id_A"] = tw_a
sa._tool_widgets["id_B"] = tw_b
sa._running_ids = ["id_A", "id_B"]
# Out-of-order delivery: result for B arrives first.
sa.complete_tool("execute", "out B", True, tool_id="id_B")
assert tw_b._status == "success"
assert tw_a._status == "running"
assert sa._completed_ids == ["id_B"]
assert sa._running_ids == ["id_A"]
# Duplicate delivery for id_B must not inflate counts.
sa.complete_tool("execute", "out B dup", True, tool_id="id_B")
assert sa._completed_ids == ["id_B"]
# Now id_A finishes normally.
sa.complete_tool("execute", "out A", True, tool_id="id_A")
assert tw_a._status == "success"
assert sa._completed_ids == ["id_B", "id_A"]
assert sa._running_ids == []
@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_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,
}
# Build stub --------------------------------------------------------
class _StubApp:
"""Minimal stub that shares the real completion method bodies."""
def __init__(self):
from EvoScientist.commands._completion_engine import CompletionCandidate
self._comp_items = []
for item in comp_items or []:
if hasattr(item, "replace_start"):
self._comp_items.append(item)
else:
text, desc = item[0], item[1]
self._comp_items.append(
CompletionCandidate(
text=text,
description=desc,
replace_start=0,
replace_end=0,
)
)
self._comp_index: int = comp_index
self._fake_input = fake_input
self._fake_completions = fake_completions
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):
candidate = self._comp_items[self._comp_index]
prompt = self.query_one("#prompt")
if candidate.text.startswith("@"):
prompt.value = candidate.text + " "
else:
current = prompt.value
suffix = current[candidate.replace_end :]
sep = "" if suffix.startswith(" ") else " "
prompt.value = (
current[: candidate.replace_start]
+ candidate.text
+ sep
+ suffix
)
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, candidate in enumerate(self._comp_items):
cmd, desc = candidate.text, candidate.description
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_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}"
# ------------------------------------------------------------------
# compute_completions (shared engine)
# ------------------------------------------------------------------
def test_engine_slash_shows_top_level_commands(self):
from EvoScientist.commands._completion_engine import compute_completions
result = compute_completions("/re", 3)
assert result.kind == "commands"
assert len(result.candidates) > 0
assert all(c.text.startswith("/re") for c in result.candidates)
def test_engine_exact_match_no_space_hides(self):
from EvoScientist.commands._completion_engine import compute_completions
result = compute_completions("/help", 5)
assert result.kind == "empty"
def test_engine_non_slash_returns_empty(self):
from EvoScientist.commands._completion_engine import compute_completions
result = compute_completions("hello", 5)
assert result.kind == "empty"
def test_engine_trailing_space_shows_subcommands(self):
from EvoScientist.commands._completion_engine import compute_completions
result = compute_completions("/mcp ", 5)
assert result.kind == "subcommands"
names = {c.text for c in result.candidates}
assert "list" in names
assert "add" in names
def test_engine_subcommand_prefix_filters(self):
from EvoScientist.commands._completion_engine import compute_completions
result = compute_completions("/mcp lis", 8)
assert result.kind == "subcommands"
names = {c.text for c in result.candidates}
assert names == {"list"}
def test_engine_exact_subcommand_hides(self):
"""When the user has already typed the full subcommand (no
trailing space), the engine should hide — Tab shouldn't re-insert
the same subcommand. Mirrors the top-level exact-match rule.
"""
from EvoScientist.commands._completion_engine import compute_completions
result = compute_completions("/mcp list", 9)
assert result.kind == "empty"
assert result.candidates == []
def test_engine_exact_subcommand_with_trailing_space_hides(self):
"""When the user has typed the full subcommand plus a trailing
space (``/mcp list ``), the engine must also hide. Without this
guard Tab oscillates between adding and removing the trailing
whitespace.
"""
from EvoScientist.commands._completion_engine import compute_completions
result = compute_completions("/mcp list ", 10)
assert result.kind == "empty"
assert result.candidates == []
def test_engine_three_parts_hides(self):
from EvoScientist.commands._completion_engine import compute_completions
result = compute_completions("/mcp list a", 11)
assert result.kind == "empty"
def test_engine_non_subcommand_cmd_hides(self):
from EvoScientist.commands._completion_engine import compute_completions
result = compute_completions("/help ", 6)
assert result.kind == "empty"
def test_engine_subcommand_replace_range(self):
from EvoScientist.commands._completion_engine import compute_completions
result = compute_completions("/mcp lis", 8)
assert result.candidates[0].replace_start == 5
assert result.candidates[0].replace_end == 8
def test_engine_subcommand_trailing_space_excludes_space_from_range(self):
"""When the user has typed a partial subcommand prefix + a
trailing space (e.g. ``/mcp a ``), the engine's replace range
must exclude the trailing space — otherwise the apply step
would produce a double space (``/mcp add ``).
"""
from EvoScientist.commands._completion_engine import compute_completions
text = "/mcp a "
result = compute_completions(text, len(text)) # cursor at end
assert result.candidates
for c in result.candidates:
# ``a`` is at position 5; trailing space at position 6.
# ``replace_start=5`` (start of the partial prefix),
# ``replace_end=6`` (right after the prefix, before the
# trailing space — so the trailing space is preserved in
# the suffix during apply).
assert c.replace_start == 5
assert c.replace_end == 6
def test_engine_subcommand_trailing_space_apply_does_not_double_space(self):
"""Applying the completion for ``/mcp a `` + accept 'add' must
not produce ``/mcp add `` (double space). The engine's
replace range excludes the trailing space; the apply step
must skip the separator when the suffix already starts with one.
"""
from EvoScientist.commands._completion_engine import compute_completions
text = "/mcp a "
result = compute_completions(text, len(text))
c = result.candidates[0]
current = text
# Apply logic that mirrors the TUI ``_apply_selected_completion``.
suffix = current[c.replace_end :]
sep = "" if suffix.startswith(" ") else " "
new_value = current[: c.replace_start] + c.text + sep + suffix
# Expected: ``/mcp add `` — the ``a`` is replaced with ``add``,
# the trailing space is preserved via the suffix. No double space.
assert new_value == f"/mcp {c.text} "
def test_engine_trailing_space_replace_range(self):
from EvoScientist.commands._completion_engine import compute_completions
result = compute_completions("/mcp ", 5)
assert result.candidates[0].replace_start == 5
assert result.candidates[0].replace_end == 5
# ------------------------------------------------------------------
# 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
@unittest.skipUnless(_has_textual, "textual not installed")
class TestModelPickerWidgetOllama(unittest.TestCase):
"""ModelPickerWidget Ollama fallback: sentinel row renders under the
ollama group, selecting it enters free-text input mode, Enter confirms
with ``Picked(typed, "ollama")``, Esc returns to list, and filtering
never hides the sentinel."""
def _make_widget(self, entries=None, *, current_model=None):
"""Build a widget with a mix of providers + the sentinel row.
When ``entries`` is ``None``, a default mix (anthropic + one ollama
model + sentinel) is used. When provided, it fully replaces the
default — callers that need extra rows should pass the complete list.
"""
from unittest.mock import MagicMock
from EvoScientist.cli.widgets.model_picker import (
_CUSTOM_OLLAMA_ID,
ModelPickerWidget,
)
if entries is None:
entries = [
("claude-sonnet-4-6", "claude-sonnet-4-6", "anthropic"),
("llama3.3", "llama3.3", "ollama"),
("Custom Ollama model...", _CUSTOM_OLLAMA_ID, "ollama"),
]
w = ModelPickerWidget(entries, current_model=current_model)
# Stub out Textual-dependent side effects so we can drive actions
# directly (follows the module's "no pilot" test pattern).
w.post_message = MagicMock()
w.focus = MagicMock()
# Fake the Input child — real one requires a mounted app.
custom_input = MagicMock()
custom_input.value = ""
custom_input.display = False
w._custom_input = custom_input
return w
def _sentinel_index(self, widget):
from EvoScientist.cli.widgets.model_picker import _CUSTOM_OLLAMA_ID
for i, item in enumerate(widget._items):
if item["type"] == "model" and item.get("model_id") == _CUSTOM_OLLAMA_ID:
return i
raise AssertionError(f"sentinel not found in items: {widget._items}")
def test_sentinel_rendered_under_ollama_group(self):
w = self._make_widget()
# The sentinel must be grouped under an "ollama" header.
headers = [i["label"] for i in w._items if i["type"] == "header"]
assert "ollama" in headers
def test_selecting_regular_row_posts_picked(self):
"""Baseline: non-sentinel selection still works."""
from EvoScientist.cli.widgets.model_picker import ModelPickerWidget
w = self._make_widget()
# Find the claude row
claude_idx = next(
i
for i, item in enumerate(w._items)
if item["type"] == "model" and item["name"] == "claude-sonnet-4-6"
)
w._selected = claude_idx
w.action_select()
assert w._mode == "list"
msgs = [c.args[0] for c in w.post_message.call_args_list]
assert any(
isinstance(m, ModelPickerWidget.Picked)
and m.name == "claude-sonnet-4-6"
and m.provider == "anthropic"
for m in msgs
)
def test_selecting_sentinel_enters_input_mode(self):
w = self._make_widget()
w._selected = self._sentinel_index(w)
w.action_select()
assert w._mode == "input"
assert w._custom_input.display is True
w._custom_input.focus.assert_called_once()
# Entering input mode must NOT post any message — the user hasn't
# submitted anything yet.
w.post_message.assert_not_called()
def test_enter_with_typed_name_posts_picked(self):
from EvoScientist.cli.widgets.model_picker import ModelPickerWidget
w = self._make_widget()
w._mode = "input"
w._custom_input.value = "qwen3-coder-next"
w._custom_input.display = True
w.action_select()
msgs = [c.args[0] for c in w.post_message.call_args_list]
picked = [m for m in msgs if isinstance(m, ModelPickerWidget.Picked)]
assert len(picked) == 1
assert picked[0].name == "qwen3-coder-next"
assert picked[0].provider == "ollama"
def test_enter_with_empty_input_is_noop(self):
w = self._make_widget()
w._mode = "input"
w._custom_input.value = ""
w.action_select()
w.post_message.assert_not_called()
# Still in input mode — user can keep typing or Esc out.
assert w._mode == "input"
def test_enter_with_whitespace_only_input_is_noop(self):
w = self._make_widget()
w._mode = "input"
w._custom_input.value = " \t "
w.action_select()
w.post_message.assert_not_called()
assert w._mode == "input"
def test_esc_in_input_mode_returns_to_list(self):
from EvoScientist.cli.widgets.model_picker import ModelPickerWidget
w = self._make_widget()
w._mode = "input"
w._custom_input.value = "partial"
w._custom_input.display = True
w.action_cancel()
assert w._mode == "list"
assert w._custom_input.display is False
assert w._custom_input.value == ""
# No Cancelled message — Esc from input returns to list, not closes.
cancelled = [
c.args[0]
for c in w.post_message.call_args_list
if isinstance(c.args[0], ModelPickerWidget.Cancelled)
]
assert cancelled == []
def test_esc_in_list_mode_cancels(self):
from EvoScientist.cli.widgets.model_picker import ModelPickerWidget
w = self._make_widget()
w._mode = "list"
w.action_cancel()
msgs = [c.args[0] for c in w.post_message.call_args_list]
assert any(isinstance(m, ModelPickerWidget.Cancelled) for m in msgs)
def test_up_in_input_mode_exits_to_list(self):
w = self._make_widget()
w._mode = "input"
w._custom_input.display = True
w.action_move_up()
assert w._mode == "list"
assert w._custom_input.display is False
def test_down_in_input_mode_absorbed(self):
w = self._make_widget()
w._mode = "input"
before_selected = w._selected
w._custom_input.display = True
w.action_move_down()
# State unchanged — key was absorbed.
assert w._mode == "input"
assert w._custom_input.display is True
assert w._selected == before_selected
def test_backspace_in_input_mode_no_filter_change(self):
w = self._make_widget()
w._mode = "input"
w._filter_text = "foo"
w.action_backspace()
# Input widget handles its own backspace — filter unchanged.
assert w._filter_text == "foo"
def test_printable_key_in_input_mode_does_not_filter(self):
from unittest.mock import MagicMock
w = self._make_widget()
w._mode = "input"
w._filter_text = ""
event = MagicMock()
event.key = "a"
event.character = "a"
w.on_key(event)
assert w._filter_text == ""
def test_duplicate_sentinels_collapsed(self):
"""Defense-in-depth: even if callers pass two sentinel rows (state
reuse, stale merges), only one "Custom Ollama model..." renders."""
from EvoScientist.cli.widgets.model_picker import (
_CUSTOM_OLLAMA_ID,
_build_items,
)
entries = [
("claude-sonnet-4-6", "claude-sonnet-4-6", "anthropic"),
("Custom Ollama model...", _CUSTOM_OLLAMA_ID, "ollama"),
("Custom Ollama model...", _CUSTOM_OLLAMA_ID, "ollama"),
("Custom Ollama model...", _CUSTOM_OLLAMA_ID, "ollama"),
]
items = _build_items(entries)
sentinel_rows = [
i
for i in items
if i["type"] == "model" and i.get("model_id") == _CUSTOM_OLLAMA_ID
]
assert len(sentinel_rows) == 1, f"duplicate sentinels rendered: {items}"
def test_sentinel_survives_filter(self):
"""The Custom Ollama row is the user's escape hatch — filtering
must never hide it."""
from EvoScientist.cli.widgets.model_picker import (
_CUSTOM_OLLAMA_ID,
_build_items,
)
entries = [
("claude-sonnet-4-6", "claude-sonnet-4-6", "anthropic"),
("llama3.3", "llama3.3", "ollama"),
("Custom Ollama model...", _CUSTOM_OLLAMA_ID, "ollama"),
]
# A filter that matches NOTHING in the normal entries.
items = _build_items(entries, filter_text="zzzzzz")
sentinel_rows = [
i
for i in items
if i["type"] == "model" and i.get("model_id") == _CUSTOM_OLLAMA_ID
]
assert len(sentinel_rows) == 1, f"sentinel hidden by filter: {items}"
def test_on_input_submitted_routes_to_submit(self):
"""Belt-and-suspenders: Enter fired inside the Input widget should
be handled the same way as action_select in input mode."""
from unittest.mock import MagicMock
from EvoScientist.cli.widgets.model_picker import ModelPickerWidget
w = self._make_widget()
w._mode = "input"
w._custom_input.value = "mymodel"
event = MagicMock()
event.input = w._custom_input # the Input child we stubbed
w.on_input_submitted(event)
event.stop.assert_called_once()
msgs = [c.args[0] for c in w.post_message.call_args_list]
picked = [m for m in msgs if isinstance(m, ModelPickerWidget.Picked)]
assert len(picked) == 1
assert picked[0].name == "mymodel"
assert picked[0].provider == "ollama"
if __name__ == "__main__":
unittest.main()