Files
zooqueenandHanzo Dev b144973bfc fix(lint): resolve all ruff + black errors across all packages
Fix 35 ruff errors (E722, F841, B904, B905, B007, E741, S202, S310,
S306, S110, TID251, UP042, F401) and run black on 526 files.

Co-authored-by: Hanzo Dev <[email protected]>
2026-02-21 23:32:32 -08:00

662 lines
22 KiB
Python

"""Test search result quality and relevance scoring for search."""
import asyncio
import sys
import tempfile
from pathlib import Path
import pytest
from tests.test_utils import create_permission_manager
sys.path.insert(0, str(Path(__file__).parent.parent))
from hanzo_mcp.tools.vector.ast_analyzer import ASTAnalyzer
class TestSearchQuality:
"""Test suite for search result quality and relevance."""
@pytest.fixture
def test_codebase(self):
"""Create a realistic test codebase."""
with tempfile.TemporaryDirectory() as tmpdir:
test_dir = Path(tmpdir)
# Create a realistic Python project
# Main application file
main_py = test_dir / "main.py"
main_py.write_text('''
"""Main application with error handling."""
import logging
from typing import List, Optional
from data_processor import DataProcessor
logger = logging.getLogger(__name__)
class ApplicationError(Exception):
"""Custom application error."""
pass
def main():
"""Main function with comprehensive error handling."""
try:
processor = DataProcessor()
data = ["item1", "item2", "item3"]
results = processor.process_data(data)
logger.info(f"Processed {len(results)} items successfully")
return results
except ApplicationError as e:
logger.error(f"Application error: {e}")
return []
except Exception as e:
logger.critical(f"Unexpected error in main: {e}")
raise
def validate_input(data: List[str]) -> bool:
"""Validate input data with error handling."""
try:
return all(isinstance(item, str) and item.strip() for item in data)
except (TypeError, AttributeError):
return False
if __name__ == "__main__":
main()
''')
# Data processor module
processor_py = test_dir / "data_processor.py"
processor_py.write_text('''
"""Data processing module with error handling."""
import logging
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
class DataProcessor:
"""Process data with comprehensive error handling."""
def __init__(self, config: Optional[Dict[str, Any]] = None):
"""Initialize processor with configuration."""
self.config = config or {}
logger.info("DataProcessor initialized")
def process_data(self, items: List[str]) -> List[str]:
"""Process a list of items with error handling."""
processed = []
for item in items:
try:
result = self._process_single_item(item)
processed.append(result)
except ValueError as e:
logger.error(f"Error processing item {item}: {e}")
continue
except Exception as e:
logger.critical(f"Unexpected processing error: {e}")
raise
return processed
def _process_single_item(self, item: str) -> str:
"""Process a single item with validation."""
if not item or not item.strip():
raise ValueError("Empty or whitespace-only item")
# Transform the item
return item.upper().strip()
def batch_process(self, data_batches: List[List[str]]) -> Dict[str, List[str]]:
"""Process multiple batches of data."""
results = {}
for i, batch in enumerate(data_batches):
batch_key = f"batch_{i}"
try:
batch_results = self.process_data(batch)
results[batch_key] = batch_results
except Exception as e:
logger.error(f"Error processing {batch_key}: {e}")
results[batch_key] = []
return results
''')
# Utility module
utils_py = test_dir / "utils.py"
utils_py.write_text('''
"""Utility functions with error handling."""
import re
from typing import Union, Optional, List
def validate_email(email: str) -> bool:
"""Validate email address with error handling."""
try:
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))
except (TypeError, re.error):
return False
def safe_divide(a: Union[int, float], b: Union[int, float]) -> Optional[float]:
"""Safely divide two numbers with error handling."""
try:
if b == 0:
raise ValueError("Division by zero")
return float(a) / float(b)
except (TypeError, ValueError) as e:
print(f"Division error: {e}")
return None
def format_error_message(error: Exception, context: str = "") -> str:
"""Format error messages consistently."""
error_type = type(error).__name__
error_msg = str(error)
if context:
return f"[{context}] {error_type}: {error_msg}"
else:
return f"{error_type}: {error_msg}"
def extract_function_names(code: str) -> List[str]:
"""Extract function names from Python code."""
try:
pattern = r'def\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\('
matches = re.findall(pattern, code)
return matches
except re.error:
return []
class ErrorHandler:
"""Centralized error handling class."""
def __init__(self, log_errors: bool = True):
self.log_errors = log_errors
self.error_count = 0
def handle_error(self, error: Exception, context: str = "") -> str:
"""Handle an error and return formatted message."""
self.error_count += 1
message = format_error_message(error, context)
if self.log_errors:
print(f"Error #{self.error_count}: {message}")
return message
''')
# Test file
test_py = test_dir / "test_main.py"
test_py.write_text('''
"""Test file for the main application."""
import unittest
from unittest.mock import patch, MagicMock
from main import main, validate_input, ApplicationError
from data_processor import DataProcessor
class TestMain(unittest.TestCase):
"""Test cases for main application."""
def test_main_success(self):
"""Test successful main execution."""
with patch('main.DataProcessor') as mock_processor:
mock_instance = MagicMock()
mock_instance.process_data.return_value = ["ITEM1", "ITEM2", "ITEM3"]
mock_processor.return_value = mock_instance
result = main()
self.assertEqual(result, ["ITEM1", "ITEM2", "ITEM3"])
def test_main_application_error(self):
"""Test main with application error."""
with patch('main.DataProcessor') as mock_processor:
mock_instance = MagicMock()
mock_instance.process_data.side_effect = ApplicationError("Test error")
mock_processor.return_value = mock_instance
result = main()
self.assertEqual(result, [])
def test_validate_input_valid(self):
"""Test input validation with valid data."""
data = ["item1", "item2", "item3"]
self.assertTrue(validate_input(data))
def test_validate_input_invalid(self):
"""Test input validation with invalid data."""
self.assertFalse(validate_input(["", "item2"]))
self.assertFalse(validate_input([None, "item2"]))
self.assertFalse(validate_input("not a list"))
class TestDataProcessor(unittest.TestCase):
"""Test cases for data processor."""
def setUp(self):
"""Set up test processor."""
self.processor = DataProcessor()
def test_process_data_success(self):
"""Test successful data processing."""
data = ["item1", "item2", "item3"]
result = self.processor.process_data(data)
expected = ["ITEM1", "ITEM2", "ITEM3"]
self.assertEqual(result, expected)
def test_process_data_with_empty_items(self):
"""Test processing with empty items."""
data = ["item1", "", "item3"]
result = self.processor.process_data(data)
# Empty item should be skipped
expected = ["ITEM1", "ITEM3"]
self.assertEqual(result, expected)
if __name__ == "__main__":
unittest.main()
''')
yield {
"dir": test_dir,
"main": main_py,
"processor": processor_py,
"utils": utils_py,
"test": test_py,
}
def test_search_relevance_scoring(self, tool_helper, test_codebase):
"""Test that search results are properly scored for relevance."""
# Test different search scenarios and expected relevance order
test_cases = [
{
"query": "error handling",
"expected_files": ["main.py", "data_processor.py", "utils.py"],
"description": "Natural language query should find relevant files",
},
{
"query": "DataProcessor",
"expected_files": ["data_processor.py", "main.py", "test_main.py"],
"description": "Class name should prioritize definition file",
},
{
"query": "def process_data",
"expected_files": ["data_processor.py"],
"description": "Function definition should find exact matches",
},
{
"query": "ApplicationError",
"expected_files": ["main.py", "test_main.py"],
"description": "Custom exception should find definition and usage",
},
]
permission_manager = create_permission_manager([str(test_codebase["dir"])])
# Test with different search types
search_tools = {
"grep": lambda: self._test_grep_relevance(
test_codebase, test_cases, permission_manager
),
"ast": lambda: self._test_ast_relevance(
test_codebase, test_cases, permission_manager
),
"symbol": lambda: self._test_symbol_relevance(
test_codebase, test_cases, permission_manager
),
}
results = {}
for tool_name, test_func in search_tools.items():
print(f"\\n=== Testing {tool_name.upper()} Search Relevance ===")
results[tool_name] = test_func()
return results
def _test_grep_relevance(self, test_codebase, test_cases, permission_manager):
"""Test grep search relevance."""
from hanzo_tools.filesystem.grep import Grep
grep_tool = Grep(permission_manager)
results = {}
class MockContext:
def __init__(self):
self.meta = {}
for case in test_cases:
query = case["query"]
expected_files = case["expected_files"]
try:
# Use asyncio.run for individual calls
result = asyncio.run(
grep_tool.call(
MockContext(),
pattern=query,
path=str(test_codebase["dir"]),
include="*.py",
)
)
# Extract found files
found_files = set()
if "Found" in result:
lines = result.split("\\n")
for line in lines:
if ":" in line and line.strip():
try:
file_path = line.split(":")[0]
if file_path:
found_files.add(Path(file_path).name)
except Exception:
continue
# Calculate relevance score
expected_set = set(expected_files)
precision = (
len(found_files & expected_set) / len(found_files)
if found_files
else 0
)
recall = (
len(found_files & expected_set) / len(expected_set)
if expected_set
else 0
)
f1_score = (
2 * (precision * recall) / (precision + recall)
if (precision + recall) > 0
else 0
)
results[query] = {
"found_files": list(found_files),
"expected_files": expected_files,
"precision": precision,
"recall": recall,
"f1_score": f1_score,
"success": True,
}
print(f"Query: '{query}'")
print(f" Found: {sorted(found_files)}")
print(f" Expected: {expected_files}")
print(f" F1 Score: {f1_score:.3f}")
except Exception as e:
results[query] = {"success": False, "error": str(e)}
print(f"Query: '{query}' - FAILED: {e}")
return results
def _test_ast_relevance(self, test_codebase, test_cases, permission_manager):
"""Test AST search relevance."""
from hanzo_tools.filesystem.symbols import SymbolsTool
ast_tool = SymbolsTool(permission_manager)
results = {}
class MockContext:
def __init__(self):
self.meta = {}
for case in test_cases:
query = case["query"]
expected_files = case["expected_files"]
try:
result = asyncio.run(
ast_tool.call(
MockContext(),
pattern=query,
path=str(test_codebase["dir"]),
ignore_case=False,
line_number=True,
)
)
# Extract found files from AST results
found_files = set()
if result and not result.startswith("No matches"):
lines = result.split("\\n")
for line in lines:
if line.endswith(":") and "/" in line:
file_path = line[:-1]
found_files.add(Path(file_path).name)
# Calculate relevance metrics
expected_set = set(expected_files)
precision = (
len(found_files & expected_set) / len(found_files)
if found_files
else 0
)
recall = (
len(found_files & expected_set) / len(expected_set)
if expected_set
else 0
)
f1_score = (
2 * (precision * recall) / (precision + recall)
if (precision + recall) > 0
else 0
)
results[query] = {
"found_files": list(found_files),
"expected_files": expected_files,
"precision": precision,
"recall": recall,
"f1_score": f1_score,
"success": True,
}
print(f"Query: '{query}'")
print(f" Found: {sorted(found_files)}")
print(f" Expected: {expected_files}")
print(f" F1 Score: {f1_score:.3f}")
except Exception as e:
results[query] = {"success": False, "error": str(e)}
print(f"Query: '{query}' - FAILED: {e}")
return results
def _test_symbol_relevance(self, test_codebase, test_cases, permission_manager):
"""Test symbol search relevance."""
analyzer = ASTAnalyzer()
results = {}
# Analyze all files first
file_symbols = {}
for file_path in test_codebase["dir"].rglob("*.py"):
try:
file_ast = analyzer.analyze_file(str(file_path))
if file_ast:
file_symbols[file_path.name] = file_ast.symbols
except Exception as e:
print(f"Failed to analyze {file_path}: {e}")
for case in test_cases:
query = case["query"]
expected_files = case["expected_files"]
try:
# Search for symbols matching the query
found_files = set()
for file_name, symbols in file_symbols.items():
for symbol in symbols:
if query.lower() in symbol.name.lower():
found_files.add(file_name)
break
# Calculate relevance metrics
expected_set = set(expected_files)
precision = (
len(found_files & expected_set) / len(found_files)
if found_files
else 0
)
recall = (
len(found_files & expected_set) / len(expected_set)
if expected_set
else 0
)
f1_score = (
2 * (precision * recall) / (precision + recall)
if (precision + recall) > 0
else 0
)
results[query] = {
"found_files": list(found_files),
"expected_files": expected_files,
"precision": precision,
"recall": recall,
"f1_score": f1_score,
"success": True,
}
print(f"Query: '{query}'")
print(f" Found: {sorted(found_files)}")
print(f" Expected: {expected_files}")
print(f" F1 Score: {f1_score:.3f}")
except Exception as e:
results[query] = {"success": False, "error": str(e)}
print(f"Query: '{query}' - FAILED: {e}")
return results
def test_search_performance_comparison(self, tool_helper, test_codebase):
"""Compare performance across different search methods."""
import time
permission_manager = create_permission_manager([str(test_codebase["dir"])])
queries = ["error", "DataProcessor", "def.*process", "import.*typing"]
from hanzo_tools.filesystem.grep import Grep
from hanzo_tools.filesystem.symbols import SymbolsTool
grep_tool = Grep(permission_manager)
ast_tool = SymbolsTool(permission_manager)
class MockContext:
def __init__(self):
self.meta = {}
performance_results = {}
for query in queries:
query_results = {}
# Test grep performance
start_time = time.time()
try:
result = asyncio.run(
grep_tool.call(
MockContext(),
pattern=query,
path=str(test_codebase["dir"]),
include="*.py",
)
)
grep_time = time.time() - start_time
grep_matches = (
result.count("\\n") if result and "Found" in result else 0
)
query_results["grep"] = {
"time": grep_time,
"matches": grep_matches,
"success": True,
}
except Exception as e:
query_results["grep"] = {
"time": time.time() - start_time,
"matches": 0,
"success": False,
"error": str(e),
}
# Test AST performance
start_time = time.time()
try:
result = asyncio.run(
ast_tool.call(
MockContext(),
pattern=query,
path=str(test_codebase["dir"]),
ignore_case=False,
line_number=True,
)
)
ast_time = time.time() - start_time
ast_matches = (
result.count("\\n")
if result and not result.startswith("No matches")
else 0
)
query_results["ast"] = {
"time": ast_time,
"matches": ast_matches,
"success": True,
}
except Exception as e:
query_results["ast"] = {
"time": time.time() - start_time,
"matches": 0,
"success": False,
"error": str(e),
}
performance_results[query] = query_results
# Print performance comparison
print("\\n=== Performance Comparison ===")
print(f"{'Query':<20} {'Grep Time':<12} {'AST Time':<12} {'Speedup':<10}")
print("-" * 60)
for query, results in performance_results.items():
grep_time = results["grep"]["time"]
ast_time = results["ast"]["time"]
speedup = ast_time / grep_time if grep_time > 0 else float("inf")
print(f"{query:<20} {grep_time:<12.3f} {ast_time:<12.3f} {speedup:<10.1f}x")
return performance_results
if __name__ == "__main__":
# Run the search quality tests
tester = TestSearchQuality()
# Create test codebase
test_codebase_gen = tester.create_test_codebase()
test_codebase = next(test_codebase_gen)
try:
print("🎯 Search Quality and Relevance Testing")
print("=" * 50)
# Test search relevance
relevance_results = tester.test_search_relevance_scoring(test_codebase)
# Test performance comparison
print("\\n" + "=" * 50)
performance_results = tester.test_search_performance_comparison(test_codebase)
print("\\n✅ Search quality testing completed!")
finally:
# Cleanup handled by context manager
pass