Files
EvoScientist/tests/test_tui_widgets.py
T

452 lines
16 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 (
LoadingWidget,
ThinkingWidget,
AssistantMessage,
ToolCallWidget,
SubAgentWidget,
TodoWidget,
UserMessage,
SystemMessage,
)
# 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"
if __name__ == "__main__":
unittest.main()