""" Tests for BaseSearchEngine. """ import json from pathlib import Path from unittest.mock import Mock from local_deep_research.config.constants import DEFAULT_MAX_FILTERED_RESULTS from local_deep_research.web_search_engines.search_engine_base import ( BaseSearchEngine, AdaptiveWait, ) from local_deep_research.web_search_engines.rate_limiting import ( RateLimitError, ) def test_default_max_filtered_results_matches_default_settings_json(): """``DEFAULT_MAX_FILTERED_RESULTS`` (the Python fallback used when no DB is available) must match the shipping value in ``defaults/default_settings.json`` — otherwise a user with a seeded DB and a programmatic caller without one see different filter caps.""" defaults_path = ( Path(__file__).resolve().parents[2] / "src" / "local_deep_research" / "defaults" / "default_settings.json" ) settings = json.loads(defaults_path.read_text()) assert ( settings["search.max_filtered_results"]["value"] == DEFAULT_MAX_FILTERED_RESULTS ) def _make_structured_llm(indices): """Create a mock LLM whose ``invoke()`` returns the given indices as a comma-separated text response (matches the relevance_filter contract).""" mock_llm = Mock() mock_llm.invoke.return_value = ", ".join(str(i) for i in indices) return mock_llm def _make_error_llm(exc): """Create a mock LLM where ``invoke()`` raises.""" mock_llm = Mock() mock_llm.invoke.side_effect = exc return mock_llm class TestBaseSearchEngineClassAttributes: """Tests for BaseSearchEngine class attributes.""" def test_is_public_default_false(self): """is_public defaults to False for safety.""" assert BaseSearchEngine.is_public is False def test_is_generic_default_false(self): """is_generic defaults to False.""" assert BaseSearchEngine.is_generic is False def test_is_scientific_default_false(self): """is_scientific defaults to False.""" assert BaseSearchEngine.is_scientific is False def test_is_local_default_false(self): """is_local defaults to False.""" assert BaseSearchEngine.is_local is False def test_is_news_default_false(self): """is_news defaults to False.""" assert BaseSearchEngine.is_news is False def test_is_code_default_false(self): """is_code defaults to False.""" assert BaseSearchEngine.is_code is False class TestLoadEngineClass: """Tests for _load_engine_class method.""" def test_load_engine_class_missing_module_path(self): """Returns error when module_path is missing.""" config = {"class_name": "TestEngine"} success, engine_class, error = BaseSearchEngine._load_engine_class( "test", config ) assert success is False assert engine_class is None assert "module_path" in error def test_load_engine_class_missing_class_name(self): """Returns error when class_name is missing.""" config = {"module_path": ".engines.test"} success, engine_class, error = BaseSearchEngine._load_engine_class( "test", config ) assert success is False assert engine_class is None assert "class_name" in error def test_load_engine_class_import_error(self): """Returns error when import fails.""" config = { "module_path": ".engines.nonexistent_module", "class_name": "NonexistentEngine", } success, engine_class, error = BaseSearchEngine._load_engine_class( "test", config ) assert success is False assert engine_class is None assert "Could not load" in error or "Security error" in error def test_load_engine_class_success(self): """Successfully loads engine class.""" config = { "module_path": ".engines.search_engine_wikipedia", "class_name": "WikipediaSearchEngine", } success, engine_class, error = BaseSearchEngine._load_engine_class( "wikipedia", config ) assert success is True assert engine_class is not None assert error is None class TestBaseSearchEngineSubclassing: """Tests for subclassing BaseSearchEngine.""" def test_subclass_can_override_attributes(self): """Subclass can override class attributes.""" class PublicSearchEngine(BaseSearchEngine): is_public = True is_generic = True def run(self, query): return [] assert PublicSearchEngine.is_public is True assert PublicSearchEngine.is_generic is True def test_subclass_scientific_engine(self): """Subclass can be marked as scientific.""" class ScientificEngine(BaseSearchEngine): is_scientific = True is_public = True def run(self, query): return [] assert ScientificEngine.is_scientific is True assert ScientificEngine.is_public is True def test_subclass_local_engine(self): """Subclass can be marked as local.""" class LocalEngine(BaseSearchEngine): is_local = True is_public = False def run(self, query): return [] assert LocalEngine.is_local is True assert LocalEngine.is_public is False def test_subclass_news_engine(self): """Subclass can be marked as news engine.""" class NewsEngine(BaseSearchEngine): is_news = True is_public = True def run(self, query): return [] assert NewsEngine.is_news is True def test_subclass_code_engine(self): """Subclass can be marked as code engine.""" class CodeEngine(BaseSearchEngine): is_code = True is_public = True def run(self, query): return [] assert CodeEngine.is_code is True class TestSearchResultValidation: """Tests for search result validation.""" def test_result_structure(self, mock_search_results): """Search results have expected structure.""" for result in mock_search_results: assert "title" in result assert "link" in result assert "snippet" in result assert "source" in result def test_result_link_is_url(self, mock_search_results): """Result links are valid URLs.""" for result in mock_search_results: link = result["link"] assert link.startswith("http://") or link.startswith("https://") def test_result_title_not_empty(self, mock_search_results): """Result titles are not empty.""" for result in mock_search_results: assert len(result["title"]) > 0 class ConcreteSearchEngine(BaseSearchEngine): """Concrete implementation for testing.""" is_public = True is_generic = True def __init__(self, **kwargs): super().__init__(**kwargs) self._mock_previews = [] self._mock_full_content = [] def _get_previews(self, query): return self._mock_previews def _get_full_content(self, relevant_items): return self._mock_full_content or relevant_items class TestAdaptiveWait: """Tests for AdaptiveWait class.""" def test_adaptive_wait_calls_function(self): """AdaptiveWait calls the provided function.""" mock_func = Mock(return_value=2.5) wait = AdaptiveWait(mock_func) mock_retry_state = Mock() result = wait(mock_retry_state) assert result == 2.5 mock_func.assert_called_once() def test_adaptive_wait_with_different_values(self): """AdaptiveWait returns different values based on function.""" call_count = [0] def variable_wait(): call_count[0] += 1 return call_count[0] * 1.0 wait = AdaptiveWait(variable_wait) mock_retry_state = Mock() assert wait(mock_retry_state) == 1.0 assert wait(mock_retry_state) == 2.0 assert wait(mock_retry_state) == 3.0 class TestBaseSearchEngineInit: """Tests for BaseSearchEngine initialization.""" def test_init_default_values(self): """Test initialization with default values.""" engine = ConcreteSearchEngine(programmatic_mode=True) assert engine.max_results == 10 assert engine.max_filtered_results == DEFAULT_MAX_FILTERED_RESULTS assert engine.llm is None assert engine.search_snippets_only is True assert engine.programmatic_mode is True assert engine._preview_filters == [] assert engine._content_filters == [] def test_init_custom_values(self): """Test initialization with custom values.""" mock_llm = Mock() engine = ConcreteSearchEngine( llm=mock_llm, max_results=20, max_filtered_results=10, search_snippets_only=False, programmatic_mode=True, ) assert engine.max_results == 20 assert engine.max_filtered_results == 10 assert engine.llm is mock_llm assert engine.search_snippets_only is False def test_init_none_values_use_defaults(self): """Test that None values use defaults.""" engine = ConcreteSearchEngine( max_results=None, max_filtered_results=None, programmatic_mode=True ) assert engine.max_results == 10 assert engine.max_filtered_results == DEFAULT_MAX_FILTERED_RESULTS def test_init_with_filters(self): """Test initialization with custom filters.""" preview_filter = Mock() content_filter = Mock() engine = ConcreteSearchEngine( preview_filters=[preview_filter], content_filters=[content_filter], programmatic_mode=True, ) assert len(engine._preview_filters) == 1 assert len(engine._content_filters) == 1 def test_init_with_settings_snapshot(self): """Test initialization with settings snapshot.""" snapshot = {"key": "value"} engine = ConcreteSearchEngine( settings_snapshot=snapshot, programmatic_mode=True ) assert engine.settings_snapshot == snapshot def test_init_engine_type_set(self): """Test that engine_type is set from class name.""" engine = ConcreteSearchEngine(programmatic_mode=True) assert engine.engine_type == "ConcreteSearchEngine" class TestMaxResultsProperty: """Tests for max_results property.""" def test_max_results_getter(self): """Test max_results getter.""" engine = ConcreteSearchEngine(max_results=15, programmatic_mode=True) assert engine.max_results == 15 def test_max_results_setter(self): """Test max_results setter.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine.max_results = 25 assert engine.max_results == 25 def test_max_results_setter_none_uses_default(self): """Test max_results setter with None uses default.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine.max_results = None assert engine.max_results == 10 def test_max_results_minimum_is_one(self): """Test max_results has minimum of 1.""" engine = ConcreteSearchEngine(max_results=0, programmatic_mode=True) assert engine.max_results >= 1 engine.max_results = -5 assert engine.max_results >= 1 class TestMaxFilteredResultsProperty: """Tests for max_filtered_results property.""" def test_max_filtered_results_getter(self): """Test max_filtered_results getter.""" engine = ConcreteSearchEngine( max_filtered_results=8, programmatic_mode=True ) assert engine.max_filtered_results == 8 def test_max_filtered_results_setter(self): """Test max_filtered_results setter.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine.max_filtered_results = 12 assert engine.max_filtered_results == 12 def test_max_filtered_results_setter_none_uses_default(self): """Test max_filtered_results setter with None uses default.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine.max_filtered_results = None assert engine.max_filtered_results == DEFAULT_MAX_FILTERED_RESULTS class TestRunMethod: """Tests for BaseSearchEngine.run method.""" def test_run_returns_empty_list_on_no_previews(self): """Test run returns empty list when no previews found.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine._mock_previews = [] results = engine.run("test query") assert results == [] def test_run_returns_previews_with_snippets_only(self): """Test run returns previews when search_snippets_only is True.""" engine = ConcreteSearchEngine( search_snippets_only=True, programmatic_mode=True ) engine._mock_previews = [ { "title": "Test", "snippet": "Content", "link": "https://example.com", } ] results = engine.run("test query") assert len(results) == 1 assert results[0]["title"] == "Test" def test_run_applies_preview_filters(self): """Test run applies preview filters.""" mock_filter = Mock() mock_filter.filter_results.return_value = [ {"title": "Filtered", "snippet": "Content"} ] engine = ConcreteSearchEngine( preview_filters=[mock_filter], programmatic_mode=True ) engine._mock_previews = [ {"title": "Original", "snippet": "Content"}, {"title": "Also Original", "snippet": "More content"}, ] results = engine.run("test query") mock_filter.filter_results.assert_called_once() assert len(results) == 1 assert results[0]["title"] == "Filtered" def test_run_applies_content_filters(self): """Test run applies content filters.""" mock_filter = Mock() mock_filter.filter_results.return_value = [ {"title": "Content Filtered", "content": "Full content"} ] engine = ConcreteSearchEngine( content_filters=[mock_filter], search_snippets_only=False, programmatic_mode=True, ) engine._mock_previews = [{"title": "Original", "snippet": "Content"}] engine._mock_full_content = [ {"title": "Original", "content": "Full content"} ] results = engine.run("test query") mock_filter.filter_results.assert_called_once() assert len(results) == 1 assert results[0]["title"] == "Content Filtered" def test_run_gets_full_content_when_not_snippets_only(self): """Test run gets full content when search_snippets_only is False.""" engine = ConcreteSearchEngine( search_snippets_only=False, programmatic_mode=True ) engine._mock_previews = [{"title": "Preview", "snippet": "Short"}] engine._mock_full_content = [ {"title": "Preview", "content": "Full content"} ] results = engine.run("test query") assert len(results) == 1 assert results[0]["content"] == "Full content" def test_run_handles_exception_gracefully(self): """Test run handles exceptions gracefully.""" class FailingEngine(ConcreteSearchEngine): def _get_previews(self, query): raise ValueError("Test error") engine = FailingEngine(programmatic_mode=True) results = engine.run("test query") assert results == [] def test_run_with_research_context(self): """Test run with research context parameter.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine._mock_previews = [{"title": "Test", "snippet": "Content"}] context = {"research_id": 123} results = engine.run("test query", research_context=context) assert len(results) == 1 class TestInvokeMethod: """Tests for invoke method (LangChain compatibility).""" def test_invoke_calls_run(self): """Test invoke calls run method.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine._mock_previews = [{"title": "Test", "snippet": "Content"}] results = engine.invoke("test query") assert len(results) == 1 assert results[0]["title"] == "Test" class TestFilterForRelevance: """Tests for _filter_for_relevance method.""" def test_filter_no_llm_returns_all(self): """Test that no LLM returns all previews.""" engine = ConcreteSearchEngine(llm=None, programmatic_mode=True) previews = [ {"title": "A", "snippet": "Content A"}, {"title": "B", "snippet": "Content B"}, ] result = engine._filter_for_relevance(previews, "query") assert len(result) == 2 def test_filter_single_preview_returns_all(self): """Test that single preview returns as-is.""" mock_llm = Mock() engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True) previews = [{"title": "A", "snippet": "Content A"}] result = engine._filter_for_relevance(previews, "query") assert len(result) == 1 mock_llm.invoke.assert_not_called() def test_filter_with_valid_llm_response(self): """Test filtering with valid LLM response.""" mock_llm = _make_structured_llm([0, 2]) engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True) previews = [ {"title": "A", "snippet": "Content A", "url": "https://a.com"}, {"title": "B", "snippet": "Content B", "url": "https://b.com"}, {"title": "C", "snippet": "Content C", "url": "https://c.com"}, ] result = engine._filter_for_relevance(previews, "query") assert len(result) == 2 assert result[0]["title"] == "A" assert result[1]["title"] == "C" def test_filter_with_out_of_range_indices(self): """Test filtering skips out-of-range indices.""" mock_llm = _make_structured_llm([0, 10, 1]) # 10 is out of range engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True) previews = [ {"title": "A", "snippet": "Content A", "url": "https://a.com"}, {"title": "B", "snippet": "Content B", "url": "https://b.com"}, ] result = engine._filter_for_relevance(previews, "query") assert len(result) == 2 # Only valid indices 0 and 1 def test_filter_with_parse_error_returns_capped_fallback(self): """Parse errors return a capped slice of the previews so the downstream pipeline isn't fed empty results when the filter is unavailable. Cap is ``max_filtered_results or DEFAULT_MAX_FILTERED_RESULTS``.""" mock_llm = _make_error_llm( ValueError("Failed to parse structured output") ) engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True) previews = [ {"title": "A", "snippet": "Content A", "url": "https://a.com"}, {"title": "B", "snippet": "Content B", "url": "https://b.com"}, {"title": "C", "snippet": "Content C", "url": "https://c.com"}, ] result = engine._filter_for_relevance(previews, "query") # All 3 previews fit under DEFAULT_MAX_FILTERED_RESULTS assert result == previews def test_filter_limits_to_max_filtered_results(self): """Test filtering limits results to max_filtered_results.""" mock_llm = _make_structured_llm([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) engine = ConcreteSearchEngine( llm=mock_llm, max_filtered_results=3, programmatic_mode=True ) previews = [ { "title": str(i), "snippet": f"Content {i}", "url": f"https://{i}.com", } for i in range(10) ] result = engine._filter_for_relevance(previews, "query") assert len(result) == 3 def test_filter_handles_llm_exception(self): """LLM exceptions return a capped slice of the previews so the downstream pipeline isn't fed empty results when the filter is unavailable. Cap is ``max_filtered_results or DEFAULT_MAX_FILTERED_RESULTS``.""" mock_llm = _make_error_llm(Exception("LLM error")) engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True) previews = [ {"title": "A", "snippet": "Content A", "url": "https://a.com"}, {"title": "B", "snippet": "Content B", "url": "https://b.com"}, ] result = engine._filter_for_relevance(previews, "query") # Both previews fit under DEFAULT_MAX_FILTERED_RESULTS assert result == previews def test_filter_truncates_long_snippets(self): """Filter must not fail on snippets longer than the internal snippet cap (``_SNIPPET_CHAR_CAP`` in ``relevance_filter.py``). The truncation happens inside the prompt builder, not in the returned preview, so we just assert the call succeeds and the cap doesn't bleed into the output.""" mock_llm = _make_structured_llm([0]) engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True) long_snippet = "x" * 2000 # Longer than the 800-char cap previews = [ {"title": "A", "snippet": long_snippet, "url": "https://a.com"}, {"title": "B", "snippet": "Short", "url": "https://b.com"}, ] result = engine._filter_for_relevance(previews, "query") # Single index returned → one kept. Snippet in the returned # preview is NOT truncated — the cap only applies inside the # filter's prompt text. assert len(result) == 1 assert result[0]["snippet"] == long_snippet class TestRateLimiting: """Tests for rate limiting functionality.""" def test_get_adaptive_wait(self): """Test _get_adaptive_wait returns tracker wait time.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine.rate_tracker = Mock() engine.rate_tracker.get_wait_time.return_value = 1.5 wait_time = engine._get_adaptive_wait() assert wait_time == 1.5 assert engine._last_wait_time == 1.5 engine.rate_tracker.get_wait_time.assert_called_once_with( "ConcreteSearchEngine" ) def test_record_retry_outcome_success(self): """Test _record_retry_outcome records successful outcome.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine.rate_tracker = Mock() engine._last_wait_time = 1.0 engine._last_results_count = 5 mock_outcome = Mock() mock_outcome.failed = False mock_retry_state = Mock() mock_retry_state.outcome = mock_outcome mock_retry_state.attempt_number = 1 engine._record_retry_outcome(mock_retry_state) engine.rate_tracker.record_outcome.assert_called_once() # Check call was made with expected arguments call_args = engine.rate_tracker.record_outcome.call_args # Engine type is first positional arg assert call_args[0][0] == "ConcreteSearchEngine" # Wait time is second positional arg assert call_args[0][1] == 1.0 def test_record_retry_outcome_failure(self): """Test _record_retry_outcome records failed outcome.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine.rate_tracker = Mock() engine._last_wait_time = 2.0 mock_outcome = Mock() mock_outcome.failed = True mock_retry_state = Mock() mock_retry_state.outcome = mock_outcome mock_retry_state.attempt_number = 2 engine._record_retry_outcome(mock_retry_state) engine.rate_tracker.record_outcome.assert_called_once() # Check call was made with expected arguments call_args = engine.rate_tracker.record_outcome.call_args # Engine type is first positional arg assert call_args[0][0] == "ConcreteSearchEngine" # Wait time is second positional arg assert call_args[0][1] == 2.0 def test_run_with_rate_limit_error_disabled(self): """Test run handles RateLimitError when rate limiting disabled.""" class RateLimitedEngine(ConcreteSearchEngine): def _get_previews(self, query): raise RateLimitError("Rate limited") engine = RateLimitedEngine(programmatic_mode=True) engine.rate_tracker.enabled = False results = engine.run("test query") assert results == [] class TestLLMRelevanceFilter: """Tests for LLM relevance filtering behavior.""" def test_llm_filter_disabled_by_default(self): """Test LLM filter is disabled by default.""" mock_llm = Mock() engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True) engine._mock_previews = [ {"title": "A", "snippet": "Content"}, {"title": "B", "snippet": "Content"}, ] # Without enable_llm_relevance_filter set, should return all previews results = engine.run("query") mock_llm.invoke.assert_not_called() assert len(results) == 2 def test_llm_filter_enabled(self): """Test LLM filter when enabled.""" mock_llm = _make_structured_llm([0]) engine = ConcreteSearchEngine(llm=mock_llm, programmatic_mode=True) engine.enable_llm_relevance_filter = True engine._mock_previews = [ {"title": "A", "snippet": "Content A", "url": "https://a.com"}, {"title": "B", "snippet": "Content B", "url": "https://b.com"}, ] results = engine.run("query") mock_llm.invoke.assert_called_once() assert len(results) == 1