665 lines
26 KiB
Python
Raw Permalink Normal View History

"""Test CLI functionality."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import asyncio
from click.testing import CliRunner
from rich.console import Console
import sys
from ..cli import cli, search_cmd, interactive, info, display_results, display_welcome, interactive_mode
from ..agent import SearchResponse
class TestCLICommands:
"""Test CLI command functionality."""
def test_cli_without_subcommand(self):
"""Test CLI runs interactive mode when no subcommand provided."""
runner = CliRunner()
with patch('..cli.interactive_mode') as mock_interactive:
mock_interactive.return_value = asyncio.run(asyncio.sleep(0)) # Mock async function
result = runner.invoke(cli, [], input='\n')
# Should attempt to run interactive mode
assert result.exit_code == 0 or 'KeyboardInterrupt' in str(result.exception)
def test_search_command_basic(self):
"""Test basic search command functionality."""
runner = CliRunner()
mock_response = SearchResponse(
summary="Test search results found",
key_findings=["Finding 1", "Finding 2"],
sources=["Source 1", "Source 2"],
search_strategy="semantic",
result_count=2
)
with patch('..cli.search') as mock_search:
mock_search.return_value = mock_response
result = runner.invoke(search_cmd, [
'--query', 'test query',
'--type', 'semantic',
'--count', '5'
])
# Should complete successfully
assert result.exit_code == 0
mock_search.assert_called_once()
# Verify search was called with correct parameters
call_args = mock_search.call_args
assert call_args[1]['query'] == 'test query'
assert call_args[1]['search_type'] == 'semantic'
assert call_args[1]['match_count'] == 5
def test_search_command_with_text_weight(self):
"""Test search command with text weight parameter."""
runner = CliRunner()
mock_response = SearchResponse(
summary="Hybrid search results",
key_findings=[],
sources=[],
search_strategy="hybrid",
result_count=10
)
with patch('..cli.search') as mock_search:
mock_search.return_value = mock_response
result = runner.invoke(search_cmd, [
'--query', 'test query',
'--type', 'hybrid',
'--text-weight', '0.7'
])
assert result.exit_code == 0
call_args = mock_search.call_args
assert call_args[1]['text_weight'] == 0.7
def test_search_command_error_handling(self):
"""Test search command handles errors gracefully."""
runner = CliRunner()
with patch('..cli.search') as mock_search:
mock_search.side_effect = Exception("Search failed")
result = runner.invoke(search_cmd, [
'--query', 'test query'
])
# Should exit with error code 1
assert result.exit_code == 1
assert "Error:" in result.output
assert "Search failed" in result.output
def test_interactive_command(self):
"""Test interactive command invokes interactive mode."""
runner = CliRunner()
with patch('..cli.interactive_mode') as mock_interactive:
mock_interactive.return_value = asyncio.run(asyncio.sleep(0))
result = runner.invoke(interactive, [])
# Should attempt to run interactive mode
assert result.exit_code == 0 or 'KeyboardInterrupt' in str(result.exception)
def test_info_command_success(self):
"""Test info command displays system information."""
runner = CliRunner()
mock_settings = MagicMock()
mock_settings.llm_model = "gpt-4o-mini"
mock_settings.embedding_model = "text-embedding-3-small"
mock_settings.embedding_dimension = 1536
mock_settings.default_match_count = 10
mock_settings.max_match_count = 50
mock_settings.default_text_weight = 0.3
mock_settings.db_pool_min_size = 10
mock_settings.db_pool_max_size = 20
with patch('..cli.load_settings', return_value=mock_settings):
result = runner.invoke(info, [])
assert result.exit_code == 0
assert "System Configuration" in result.output
assert "gpt-4o-mini" in result.output
assert "text-embedding-3-small" in result.output
def test_info_command_error_handling(self):
"""Test info command handles settings loading errors."""
runner = CliRunner()
with patch('..cli.load_settings') as mock_load_settings:
mock_load_settings.side_effect = Exception("Settings load failed")
result = runner.invoke(info, [])
assert result.exit_code == 1
assert "Error loading settings:" in result.output
assert "Settings load failed" in result.output
class TestDisplayFunctions:
"""Test CLI display functions."""
def test_display_welcome(self, capsys):
"""Test welcome message display."""
console = Console(file=sys.stdout, force_terminal=False)
with patch('..cli.console', console):
display_welcome()
captured = capsys.readouterr()
assert "Semantic Search Agent" in captured.out
assert "Welcome" in captured.out
assert "search" in captured.out.lower()
assert "interactive" in captured.out.lower()
def test_display_results_basic(self, capsys):
"""Test basic results display."""
console = Console(file=sys.stdout, force_terminal=False)
response = {
'summary': 'This is a test summary of the search results.',
'key_findings': ['Finding 1', 'Finding 2', 'Finding 3'],
'sources': [
{'title': 'Document 1', 'source': 'doc1.pdf'},
{'title': 'Document 2', 'source': 'doc2.pdf'}
],
'search_strategy': 'hybrid',
'result_count': 10
}
with patch('..cli.console', console):
display_results(response)
captured = capsys.readouterr()
assert "Summary:" in captured.out
assert "This is a test summary" in captured.out
assert "Key Findings:" in captured.out
assert "Finding 1" in captured.out
assert "Sources:" in captured.out
assert "Document 1" in captured.out
assert "Search Strategy: hybrid" in captured.out
assert "Results Found: 10" in captured.out
def test_display_results_minimal(self, capsys):
"""Test results display with minimal data."""
console = Console(file=sys.stdout, force_terminal=False)
response = {
'summary': 'Minimal response',
'search_strategy': 'semantic',
'result_count': 0
}
with patch('..cli.console', console):
display_results(response)
captured = capsys.readouterr()
assert "Summary:" in captured.out
assert "Minimal response" in captured.out
assert "Search Strategy: semantic" in captured.out
assert "Results Found: 0" in captured.out
def test_display_results_no_summary(self, capsys):
"""Test results display when summary is missing."""
console = Console(file=sys.stdout, force_terminal=False)
response = {
'search_strategy': 'auto',
'result_count': 5
}
with patch('..cli.console', console):
display_results(response)
captured = capsys.readouterr()
assert "Summary:" in captured.out
assert "No summary available" in captured.out
assert "Search Strategy: auto" in captured.out
class TestInteractiveMode:
"""Test interactive mode functionality."""
@pytest.mark.asyncio
async def test_interactive_mode_initialization(self):
"""Test interactive mode initializes properly."""
with patch('..cli.interactive_search') as mock_interactive_search:
with patch('..cli.display_welcome') as mock_display_welcome:
with patch('..cli.Prompt.ask') as mock_prompt:
with patch('..cli.Confirm.ask') as mock_confirm:
mock_deps = AsyncMock()
mock_interactive_search.return_value = mock_deps
mock_prompt.side_effect = ['test query', 'exit']
mock_confirm.return_value = True
await interactive_mode()
mock_display_welcome.assert_called_once()
mock_interactive_search.assert_called_once()
@pytest.mark.asyncio
async def test_interactive_mode_search_query(self):
"""Test interactive mode handles search queries."""
mock_response = SearchResponse(
summary="Interactive search results",
key_findings=["Finding 1"],
sources=["Source 1"],
search_strategy="auto",
result_count=1
)
with patch('..cli.interactive_search') as mock_interactive_search:
with patch('..cli.display_welcome'):
with patch('..cli.display_results') as mock_display_results:
with patch('..cli.search') as mock_search:
with patch('..cli.Prompt.ask') as mock_prompt:
with patch('..cli.Confirm.ask') as mock_confirm:
mock_deps = AsyncMock()
mock_interactive_search.return_value = mock_deps
mock_search.return_value = mock_response
mock_prompt.side_effect = ['Python tutorial', 'exit']
mock_confirm.return_value = True
await interactive_mode()
# Should perform search
mock_search.assert_called()
call_args = mock_search.call_args
assert call_args[1]['query'] == 'Python tutorial'
# Should display results
mock_display_results.assert_called()
@pytest.mark.asyncio
async def test_interactive_mode_help_command(self):
"""Test interactive mode handles help command."""
with patch('..cli.interactive_search') as mock_interactive_search:
with patch('..cli.display_welcome') as mock_display_welcome:
with patch('..cli.Prompt.ask') as mock_prompt:
with patch('..cli.Confirm.ask') as mock_confirm:
mock_deps = AsyncMock()
mock_interactive_search.return_value = mock_deps
mock_prompt.side_effect = ['help', 'exit']
mock_confirm.return_value = True
await interactive_mode()
# Should display welcome twice (initial + help)
assert mock_display_welcome.call_count == 2
@pytest.mark.asyncio
async def test_interactive_mode_clear_command(self):
"""Test interactive mode handles clear command."""
with patch('..cli.interactive_search') as mock_interactive_search:
with patch('..cli.display_welcome'):
with patch('..cli.console') as mock_console:
with patch('..cli.Prompt.ask') as mock_prompt:
with patch('..cli.Confirm.ask') as mock_confirm:
mock_deps = AsyncMock()
mock_interactive_search.return_value = mock_deps
mock_prompt.side_effect = ['clear', 'exit']
mock_confirm.return_value = True
await interactive_mode()
# Should clear console
mock_console.clear.assert_called_once()
@pytest.mark.asyncio
async def test_interactive_mode_set_preference(self):
"""Test interactive mode handles preference setting."""
with patch('..cli.interactive_search') as mock_interactive_search:
with patch('..cli.display_welcome'):
with patch('..cli.Prompt.ask') as mock_prompt:
with patch('..cli.Confirm.ask') as mock_confirm:
with patch('..cli.console') as mock_console:
mock_deps = AsyncMock()
mock_interactive_search.return_value = mock_deps
mock_prompt.side_effect = ['set search_type=semantic', 'exit']
mock_confirm.return_value = True
await interactive_mode()
# Should set preference on deps
mock_deps.set_user_preference.assert_called_once_with('search_type', 'semantic')
@pytest.mark.asyncio
async def test_interactive_mode_invalid_set_command(self):
"""Test interactive mode handles invalid set commands."""
with patch('..cli.interactive_search') as mock_interactive_search:
with patch('..cli.display_welcome'):
with patch('..cli.Prompt.ask') as mock_prompt:
with patch('..cli.Confirm.ask') as mock_confirm:
with patch('..cli.console') as mock_console:
mock_deps = AsyncMock()
mock_interactive_search.return_value = mock_deps
mock_prompt.side_effect = ['set invalid_format', 'exit']
mock_confirm.return_value = True
await interactive_mode()
# Should not set preference
mock_deps.set_user_preference.assert_not_called()
# Should print error message
mock_console.print.assert_called()
@pytest.mark.asyncio
async def test_interactive_mode_exit_confirmation(self):
"""Test interactive mode handles exit confirmation."""
with patch('..cli.interactive_search') as mock_interactive_search:
with patch('..cli.display_welcome'):
with patch('..cli.Prompt.ask') as mock_prompt:
with patch('..cli.Confirm.ask') as mock_confirm:
mock_deps = AsyncMock()
mock_interactive_search.return_value = mock_deps
mock_prompt.side_effect = ['exit', 'quit']
# First time say no, second time say yes
mock_confirm.side_effect = [False, True]
await interactive_mode()
# Should ask for confirmation twice
assert mock_confirm.call_count == 2
# Should cleanup dependencies
mock_deps.cleanup.assert_called_once()
@pytest.mark.asyncio
async def test_interactive_mode_search_error(self):
"""Test interactive mode handles search errors."""
with patch('..cli.interactive_search') as mock_interactive_search:
with patch('..cli.display_welcome'):
with patch('..cli.search') as mock_search:
with patch('..cli.Prompt.ask') as mock_prompt:
with patch('..cli.Confirm.ask') as mock_confirm:
with patch('..cli.console') as mock_console:
mock_deps = AsyncMock()
mock_interactive_search.return_value = mock_deps
mock_search.side_effect = Exception("Search failed")
mock_prompt.side_effect = ['test query', 'exit']
mock_confirm.return_value = True
await interactive_mode()
# Should print error message
error_calls = [call for call in mock_console.print.call_args_list
if 'Error:' in str(call)]
assert len(error_calls) > 0
class TestCLIInputValidation:
"""Test CLI input validation."""
def test_search_command_empty_query(self):
"""Test search command with empty query."""
runner = CliRunner()
result = runner.invoke(search_cmd, ['--query', ''])
# Should still accept empty query (might be valid use case)
assert result.exit_code == 0 or result.exit_code == 1 # May fail due to missing search function
def test_search_command_invalid_type(self):
"""Test search command with invalid search type."""
runner = CliRunner()
result = runner.invoke(search_cmd, [
'--query', 'test',
'--type', 'invalid_type'
])
# Should reject invalid type
assert result.exit_code != 0
assert "Invalid value" in result.output or "Usage:" in result.output
def test_search_command_invalid_count(self):
"""Test search command with invalid count."""
runner = CliRunner()
result = runner.invoke(search_cmd, [
'--query', 'test',
'--count', 'not_a_number'
])
# Should reject non-numeric count
assert result.exit_code != 0
assert ("Invalid value" in result.output or
"Usage:" in result.output or
"not_a_number is not a valid integer" in result.output)
def test_search_command_negative_count(self):
"""Test search command with negative count."""
runner = CliRunner()
mock_response = SearchResponse(
summary="Test results",
key_findings=[],
sources=[],
search_strategy="auto",
result_count=0
)
with patch('..cli.search') as mock_search:
mock_search.return_value = mock_response
result = runner.invoke(search_cmd, [
'--query', 'test',
'--count', '-5'
])
# Click accepts negative integers, but our code should handle it
assert result.exit_code == 0
call_args = mock_search.call_args
assert call_args[1]['match_count'] == -5 # Passed through
def test_search_command_invalid_text_weight(self):
"""Test search command with invalid text weight."""
runner = CliRunner()
result = runner.invoke(search_cmd, [
'--query', 'test',
'--text-weight', 'not_a_float'
])
# Should reject non-numeric text weight
assert result.exit_code != 0
assert ("Invalid value" in result.output or
"Usage:" in result.output or
"not_a_float is not a valid" in result.output)
class TestCLIIntegration:
"""Test CLI integration scenarios."""
def test_cli_with_all_parameters(self):
"""Test CLI with all possible parameters."""
runner = CliRunner()
mock_response = SearchResponse(
summary="Complete search results",
key_findings=["Finding 1", "Finding 2"],
sources=["Source 1", "Source 2"],
search_strategy="hybrid",
result_count=15
)
with patch('..cli.search') as mock_search:
mock_search.return_value = mock_response
result = runner.invoke(search_cmd, [
'--query', 'comprehensive search test',
'--type', 'hybrid',
'--count', '15',
'--text-weight', '0.6'
])
assert result.exit_code == 0
# Verify all parameters passed correctly
call_args = mock_search.call_args
assert call_args[1]['query'] == 'comprehensive search test'
assert call_args[1]['search_type'] == 'hybrid'
assert call_args[1]['match_count'] == 15
assert call_args[1]['text_weight'] == 0.6
def test_cli_search_output_format(self):
"""Test CLI search output formatting."""
runner = CliRunner()
mock_response = SearchResponse(
summary="Formatted output test results with detailed information.",
key_findings=[
"Key finding number one with details",
"Second important finding",
"Third critical insight"
],
sources=[
"Python Documentation",
"Machine Learning Guide",
"API Reference Manual"
],
search_strategy="semantic",
result_count=25
)
with patch('..cli.search') as mock_search:
mock_search.return_value = mock_response
result = runner.invoke(search_cmd, [
'--query', 'formatting test'
])
assert result.exit_code == 0
# Check that output contains expected formatted content
output = result.output
assert "Searching for:" in output
assert "formatting test" in output
assert "Summary:" in output
assert "Formatted output test results" in output
assert "Key Findings:" in output
assert "Key finding number one" in output
assert "Sources:" in output
assert "Python Documentation" in output
assert "Search Strategy: semantic" in output
assert "Results Found: 25" in output
class TestCLIErrorScenarios:
"""Test CLI error handling scenarios."""
def test_cli_keyboard_interrupt(self):
"""Test CLI handles keyboard interrupt gracefully."""
runner = CliRunner()
with patch('..cli.search') as mock_search:
mock_search.side_effect = KeyboardInterrupt()
result = runner.invoke(search_cmd, ['--query', 'test'])
# Should handle KeyboardInterrupt without crashing
assert result.exit_code != 0
def test_cli_system_exit(self):
"""Test CLI handles system exit gracefully."""
runner = CliRunner()
with patch('..cli.search') as mock_search:
mock_search.side_effect = SystemExit(1)
result = runner.invoke(search_cmd, ['--query', 'test'])
# Should handle SystemExit
assert result.exit_code == 1
def test_cli_unexpected_exception(self):
"""Test CLI handles unexpected exceptions."""
runner = CliRunner()
with patch('..cli.search') as mock_search:
mock_search.side_effect = RuntimeError("Unexpected error occurred")
result = runner.invoke(search_cmd, ['--query', 'test'])
assert result.exit_code == 1
assert "Error:" in result.output
assert "Unexpected error occurred" in result.output
class TestCLIUsability:
"""Test CLI usability features."""
def test_cli_help_messages(self):
"""Test CLI provides helpful help messages."""
runner = CliRunner()
# Test main CLI help
result = runner.invoke(cli, ['--help'])
assert result.exit_code == 0
assert "Semantic Search Agent CLI" in result.output
# Test search command help
result = runner.invoke(search_cmd, ['--help'])
assert result.exit_code == 0
assert "Perform a one-time search" in result.output
assert "--query" in result.output
assert "--type" in result.output
assert "--count" in result.output
assert "--text-weight" in result.output
# Test interactive command help
result = runner.invoke(interactive, ['--help'])
assert result.exit_code == 0
assert "interactive search session" in result.output
# Test info command help
result = runner.invoke(info, ['--help'])
assert result.exit_code == 0
assert "system information" in result.output
def test_cli_command_suggestions(self):
"""Test CLI provides command suggestions for typos."""
runner = CliRunner()
# Test with typo in command name
result = runner.invoke(cli, ['searc']) # Missing 'h'
# Should suggest correct command or show usage
assert result.exit_code != 0
assert ("Usage:" in result.output or
"No such command" in result.output or
"Did you mean" in result.output)
def test_cli_default_values(self):
"""Test CLI uses appropriate default values."""
runner = CliRunner()
mock_response = SearchResponse(
summary="Default values test",
key_findings=[],
sources=[],
search_strategy="auto",
result_count=10
)
with patch('..cli.search') as mock_search:
mock_search.return_value = mock_response
result = runner.invoke(search_cmd, ['--query', 'test with defaults'])
assert result.exit_code == 0
# Check default values were used
call_args = mock_search.call_args
assert call_args[1]['search_type'] == 'auto' # Default type
assert call_args[1]['match_count'] == 10 # Default count
assert call_args[1]['text_weight'] is None # No default text weight