"""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._comp_base: str = "" self._fake_input = fake_input self._fake_completions = fake_completions def query_one(self, selector, widget_type=None): 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 if self._comp_index < 0: self._comp_index = 0 self._render_completions() self._apply_selected_completion() selected = self._comp_items[self._comp_index] is_file_dir = selected.text.startswith("@") and selected.text.rstrip( '"' ).endswith("/") if is_file_dir: return if selected.text.startswith("@"): self._hide_completions() def _apply_selected_completion(self): candidate = self._comp_items[self._comp_index] prompt = self.query_one("#prompt") if candidate.text.startswith("@"): is_dir = candidate.text.rstrip('"').endswith("/") suffix = "" if is_dir else " " prompt.value = candidate.text + suffix else: prompt.value = self._comp_base + candidate.text + " " prompt.cursor_position = len(prompt.value) self._render_completions() def _hide_completions(self): self._comp_items = [] self._comp_index = -1 self.query_one("#completions").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 cat = getattr(candidate, "category", "") if cat and ( i == 0 or getattr(self._comp_items[i - 1], "category", "") != cat ): if i > 0: comp_text.append("\n") comp_text.append(f" {cat}\n", style="bold #6b7280") if i == self._comp_index: comp_text.append(" \u25b8 ", style="bold") comp_text.append(f"{cmd:<28}", style="bold") comp_text.append(desc, style="bold") else: comp_text.append(" ", style="#888888") comp_text.append(f"{cmd:<28}", 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): comp_widget = self.query_one("#completions") if not (comp_widget.display and self._comp_items): return False 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_applies_current_highlight(self): """TAB on an already-highlighted item should apply it, not advance.""" items = [("/resume", "d1"), ("/run", "d2")] app = self._make_app(comp_items=items, comp_index=1) # second item app._fake_completions.display = True app.action_tab_complete() assert app._comp_index == 1 assert app._fake_input.value == "/run " 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: # ``replace_start`` must point to the start of the partial # ``a`` (position 5) so that ``_comp_base`` = ``/mcp ``. assert c.replace_start == 5 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 TUI uses ``_comp_base + candidate.text + " "`` which avoids double spaces. """ from EvoScientist.commands._completion_engine import compute_completions text = "/mcp a " result = compute_completions(text, len(text)) c = result.candidates[0] # TUI apply logic: base (text[:replace_start]) + candidate + " " comp_base = text[: c.replace_start] new_value = comp_base + c.text + " " 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()