452 lines
16 KiB
Python
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()
|