Skip to content

Commit 4f354f2

Browse files
committed
chore: more test coverage
1 parent 1f59449 commit 4f354f2

File tree

5 files changed

+378
-0
lines changed

5 files changed

+378
-0
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import pytest
2+
from textual.app import App, ComposeResult
3+
4+
from agent_chat_cli.components.chat_history import ChatHistory
5+
from agent_chat_cli.components.messages import (
6+
Message,
7+
SystemMessage,
8+
UserMessage,
9+
AgentMessage,
10+
ToolMessage,
11+
)
12+
13+
14+
class ChatHistoryApp(App):
15+
def compose(self) -> ComposeResult:
16+
yield ChatHistory()
17+
18+
19+
class TestChatHistoryAddMessage:
20+
@pytest.fixture
21+
def app(self):
22+
return ChatHistoryApp()
23+
24+
async def test_adds_system_message(self, app):
25+
async with app.run_test():
26+
chat_history = app.query_one(ChatHistory)
27+
chat_history.add_message(Message.system("System alert"))
28+
29+
widgets = chat_history.query(SystemMessage)
30+
assert len(widgets) == 1
31+
assert widgets.first().message == "System alert"
32+
33+
async def test_adds_user_message(self, app):
34+
async with app.run_test():
35+
chat_history = app.query_one(ChatHistory)
36+
chat_history.add_message(Message.user("Hello"))
37+
38+
widgets = chat_history.query(UserMessage)
39+
assert len(widgets) == 1
40+
assert widgets.first().message == "Hello"
41+
42+
async def test_adds_agent_message(self, app):
43+
async with app.run_test():
44+
chat_history = app.query_one(ChatHistory)
45+
chat_history.add_message(Message.agent("I can help"))
46+
47+
widgets = chat_history.query(AgentMessage)
48+
assert len(widgets) == 1
49+
assert widgets.first().message == "I can help"
50+
51+
async def test_adds_tool_message_with_json_content(self, app):
52+
async with app.run_test():
53+
chat_history = app.query_one(ChatHistory)
54+
chat_history.add_message(
55+
Message.tool("read_file", '{"path": "/tmp/test.txt"}')
56+
)
57+
58+
widgets = chat_history.query(ToolMessage)
59+
assert len(widgets) == 1
60+
assert widgets.first().tool_name == "read_file"
61+
assert widgets.first().tool_input == {"path": "/tmp/test.txt"}
62+
63+
async def test_tool_message_handles_invalid_json(self, app):
64+
async with app.run_test():
65+
chat_history = app.query_one(ChatHistory)
66+
chat_history.add_message(Message.tool("bash", "not valid json"))
67+
68+
widgets = chat_history.query(ToolMessage)
69+
assert len(widgets) == 1
70+
assert widgets.first().tool_input == {"raw": "not valid json"}
71+
72+
async def test_adds_multiple_messages(self, app):
73+
async with app.run_test():
74+
chat_history = app.query_one(ChatHistory)
75+
chat_history.add_message(Message.user("First"))
76+
chat_history.add_message(Message.agent("Second"))
77+
chat_history.add_message(Message.user("Third"))
78+
79+
assert len(chat_history.children) == 3

tests/components/test_header.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import pytest
2+
from unittest.mock import MagicMock, patch
3+
4+
from textual.app import App, ComposeResult
5+
from textual.widgets import Label
6+
7+
from agent_chat_cli.components.header import Header
8+
from agent_chat_cli.utils.mcp_server_status import MCPServerStatus
9+
10+
11+
@pytest.fixture(autouse=True)
12+
def reset_mcp_status():
13+
MCPServerStatus._mcp_servers = []
14+
MCPServerStatus._callbacks = []
15+
yield
16+
MCPServerStatus._mcp_servers = []
17+
MCPServerStatus._callbacks = []
18+
19+
20+
@pytest.fixture
21+
def mock_config():
22+
with patch("agent_chat_cli.components.header.load_config") as mock:
23+
mock.return_value = MagicMock(
24+
mcp_servers={"filesystem": MagicMock(), "github": MagicMock()},
25+
agents={"researcher": MagicMock()},
26+
)
27+
yield mock
28+
29+
30+
class HeaderApp(App):
31+
def compose(self) -> ComposeResult:
32+
yield Header()
33+
34+
35+
class TestHeaderMCPServerStatus:
36+
async def test_subscribes_on_mount(self, mock_config):
37+
app = HeaderApp()
38+
async with app.run_test():
39+
assert len(MCPServerStatus._callbacks) == 1
40+
41+
async def test_updates_label_on_status_change(self, mock_config):
42+
app = HeaderApp()
43+
async with app.run_test():
44+
MCPServerStatus.update(
45+
[
46+
{"name": "filesystem", "status": "connected"},
47+
{"name": "github", "status": "error"},
48+
]
49+
)
50+
51+
header = app.query_one(Header)
52+
header._handle_mcp_server_status()
53+
54+
label = app.query_one("#header-mcp-servers", Label)
55+
# Label stores markup in _content or we can check via render
56+
content = label.render()
57+
rendered = str(content)
58+
59+
assert "filesystem" in rendered
60+
assert "github" in rendered
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import pytest
2+
from textual.app import App, ComposeResult
3+
4+
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
5+
6+
7+
class ThinkingIndicatorApp(App):
8+
def compose(self) -> ComposeResult:
9+
yield ThinkingIndicator()
10+
11+
12+
class TestThinkingIndicator:
13+
@pytest.fixture
14+
def app(self):
15+
return ThinkingIndicatorApp()
16+
17+
async def test_hidden_by_default(self, app):
18+
async with app.run_test():
19+
indicator = app.query_one(ThinkingIndicator)
20+
assert indicator.display is False
21+
22+
async def test_is_thinking_true_shows_indicator(self, app):
23+
async with app.run_test():
24+
indicator = app.query_one(ThinkingIndicator)
25+
indicator.is_thinking = True
26+
27+
assert indicator.display is True
28+
29+
async def test_is_thinking_false_hides_indicator(self, app):
30+
async with app.run_test():
31+
indicator = app.query_one(ThinkingIndicator)
32+
indicator.is_thinking = True
33+
indicator.is_thinking = False
34+
35+
assert indicator.display is False
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import pytest
2+
from unittest.mock import AsyncMock, MagicMock
3+
4+
from textual.app import App, ComposeResult
5+
from textual.widgets import TextArea, Label
6+
7+
from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt
8+
9+
10+
class ToolPermissionPromptApp(App):
11+
def __init__(self):
12+
super().__init__()
13+
self.mock_actions = MagicMock()
14+
self.mock_actions.respond_to_tool_permission = AsyncMock()
15+
16+
def compose(self) -> ComposeResult:
17+
yield ToolPermissionPrompt(actions=self.mock_actions)
18+
19+
20+
class TestToolPermissionPromptVisibility:
21+
@pytest.fixture
22+
def app(self):
23+
return ToolPermissionPromptApp()
24+
25+
async def test_hidden_by_default(self, app):
26+
async with app.run_test():
27+
prompt = app.query_one(ToolPermissionPrompt)
28+
assert prompt.display is False
29+
30+
async def test_is_visible_true_shows_prompt(self, app):
31+
async with app.run_test():
32+
prompt = app.query_one(ToolPermissionPrompt)
33+
prompt.is_visible = True
34+
35+
assert prompt.display is True
36+
37+
async def test_is_visible_clears_input_on_show(self, app):
38+
async with app.run_test():
39+
prompt = app.query_one(ToolPermissionPrompt)
40+
input_widget = prompt.query_one("#permission-input", TextArea)
41+
input_widget.insert("leftover text")
42+
43+
prompt.is_visible = True
44+
45+
assert input_widget.text == ""
46+
47+
48+
class TestToolPermissionPromptToolDisplay:
49+
@pytest.fixture
50+
def app(self):
51+
return ToolPermissionPromptApp()
52+
53+
async def test_displays_mcp_tool_with_server_name(self, app):
54+
async with app.run_test():
55+
prompt = app.query_one(ToolPermissionPrompt)
56+
prompt.tool_name = "mcp__filesystem__read_file"
57+
58+
label = prompt.query_one("#tool-display", Label)
59+
rendered = str(label.render())
60+
61+
assert "filesystem" in rendered
62+
assert "read_file" in rendered
63+
64+
async def test_displays_non_mcp_tool(self, app):
65+
async with app.run_test():
66+
prompt = app.query_one(ToolPermissionPrompt)
67+
prompt.tool_name = "bash"
68+
69+
label = prompt.query_one("#tool-display", Label)
70+
rendered = str(label.render())
71+
72+
assert "bash" in rendered
73+
74+
75+
class TestToolPermissionPromptSubmit:
76+
@pytest.fixture
77+
def app(self):
78+
return ToolPermissionPromptApp()
79+
80+
async def test_empty_submit_defaults_to_yes(self, app):
81+
async with app.run_test():
82+
prompt = app.query_one(ToolPermissionPrompt)
83+
prompt.is_visible = True
84+
85+
await prompt.action_submit()
86+
87+
app.mock_actions.respond_to_tool_permission.assert_called_with("yes")
88+
89+
async def test_submit_with_text(self, app):
90+
async with app.run_test():
91+
prompt = app.query_one(ToolPermissionPrompt)
92+
prompt.is_visible = True
93+
94+
input_widget = prompt.query_one("#permission-input", TextArea)
95+
input_widget.insert("no")
96+
97+
await prompt.action_submit()
98+
99+
app.mock_actions.respond_to_tool_permission.assert_called_with("no")
100+
101+
async def test_escape_submits_no(self, app):
102+
async with app.run_test() as pilot:
103+
prompt = app.query_one(ToolPermissionPrompt)
104+
prompt.is_visible = True
105+
106+
await pilot.press("escape")
107+
108+
app.mock_actions.respond_to_tool_permission.assert_called_with("no")
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import pytest
2+
from unittest.mock import AsyncMock, MagicMock
3+
4+
from textual.app import App, ComposeResult
5+
from textual.widgets import TextArea
6+
7+
from agent_chat_cli.components.user_input import UserInput
8+
9+
10+
class UserInputApp(App):
11+
def __init__(self):
12+
super().__init__()
13+
self.mock_actions = MagicMock()
14+
self.mock_actions.quit = MagicMock()
15+
self.mock_actions.interrupt = AsyncMock()
16+
self.mock_actions.new = AsyncMock()
17+
self.mock_actions.submit_user_message = AsyncMock()
18+
19+
def compose(self) -> ComposeResult:
20+
yield UserInput(actions=self.mock_actions)
21+
22+
23+
class TestUserInputSubmit:
24+
@pytest.fixture
25+
def app(self):
26+
return UserInputApp()
27+
28+
async def test_empty_submit_does_nothing(self, app):
29+
async with app.run_test() as pilot:
30+
await pilot.press("enter")
31+
32+
app.mock_actions.submit_user_message.assert_not_called()
33+
34+
async def test_submits_message(self, app):
35+
async with app.run_test() as pilot:
36+
user_input = app.query_one(UserInput)
37+
text_area = user_input.query_one(TextArea)
38+
text_area.insert("Hello agent")
39+
40+
await pilot.press("enter")
41+
42+
app.mock_actions.submit_user_message.assert_called_with("Hello agent")
43+
44+
async def test_clears_input_after_submit(self, app):
45+
async with app.run_test() as pilot:
46+
user_input = app.query_one(UserInput)
47+
text_area = user_input.query_one(TextArea)
48+
text_area.insert("Hello agent")
49+
50+
await pilot.press("enter")
51+
52+
assert text_area.text == ""
53+
54+
55+
class TestUserInputControlCommands:
56+
@pytest.fixture
57+
def app(self):
58+
return UserInputApp()
59+
60+
async def test_exit_command_quits(self, app):
61+
async with app.run_test() as pilot:
62+
user_input = app.query_one(UserInput)
63+
text_area = user_input.query_one(TextArea)
64+
text_area.insert("exit")
65+
66+
await pilot.press("enter")
67+
68+
app.mock_actions.quit.assert_called_once()
69+
70+
async def test_clear_command_resets_conversation(self, app):
71+
async with app.run_test() as pilot:
72+
user_input = app.query_one(UserInput)
73+
text_area = user_input.query_one(TextArea)
74+
text_area.insert("clear")
75+
76+
await pilot.press("enter")
77+
78+
app.mock_actions.interrupt.assert_called_once()
79+
app.mock_actions.new.assert_called_once()
80+
81+
82+
class TestUserInputNewlines:
83+
@pytest.fixture
84+
def app(self):
85+
return UserInputApp()
86+
87+
async def test_ctrl_j_inserts_newline(self, app):
88+
async with app.run_test() as pilot:
89+
user_input = app.query_one(UserInput)
90+
text_area = user_input.query_one(TextArea)
91+
text_area.insert("line1")
92+
93+
await pilot.press("ctrl+j")
94+
text_area.insert("line2")
95+
96+
assert "line1\nline2" in text_area.text

0 commit comments

Comments
 (0)