""" Tests for search_system.py - AdvancedSearchSystem class. Tests cover: - __init__ settings extraction, defaults, factory wiring, follow-up delegation, logging - set_progress_callback forwarding to strategy - analyze_topic search_id generation and delegation - _perform_search settings extraction, progress callbacks, result assembly - Duplicate link avoidance (id check) - NewsSearchCallback error resilience """ from unittest.mock import MagicMock, patch import pytest # --------------------------------------------------------------------------- # Helpers / fixtures # --------------------------------------------------------------------------- def _make_strategy_mock(): """Return a MagicMock that behaves like a strategy returned by create_strategy.""" strategy = MagicMock() strategy.analyze_topic.return_value = {"current_knowledge": "test"} strategy.questions_by_iteration = [] strategy.all_links_of_system = [] strategy.set_progress_callback = MagicMock() return strategy # Patch paths: top-level imports can be patched on search_system directly, # but create_strategy and NewsSearchCallback are lazily imported inside method # bodies, so they must be patched at their source modules. PATCH_CITATION = "local_deep_research.search_system.CitationHandler" PATCH_QUESTION_GEN = ( "local_deep_research.search_system.StandardQuestionGenerator" ) PATCH_FINDINGS = "local_deep_research.search_system.FindingsRepository" PATCH_CREATE_STRATEGY = ( "local_deep_research.search_system_factory.create_strategy" ) PATCH_ENHANCED_FOLLOWUP = ( "local_deep_research.search_system.EnhancedContextualFollowUpStrategy" ) PATCH_NEWS_CALLBACK = ( "local_deep_research.news.core.search_integration.NewsSearchCallback" ) @pytest.fixture() def mock_deps(): """Patch CitationHandler, StandardQuestionGenerator, FindingsRepository, and create_strategy so that AdvancedSearchSystem can be instantiated without real dependencies. Yields a dict of mock objects.""" with ( patch(PATCH_CITATION) as m_citation, patch(PATCH_QUESTION_GEN) as m_qgen, patch(PATCH_FINDINGS) as m_findings, patch(PATCH_CREATE_STRATEGY) as m_create, ): strategy = _make_strategy_mock() m_create.return_value = strategy yield { "citation": m_citation, "question_gen": m_qgen, "findings": m_findings, "create_strategy": m_create, "strategy": strategy, } @pytest.fixture() def mock_model(): return MagicMock(name="BaseChatModel") @pytest.fixture() def mock_search(): return MagicMock(name="BaseSearchEngine") def _build_system(mock_model, mock_search, mock_deps, **kwargs): """Convenience: build an AdvancedSearchSystem with the standard mocks.""" from local_deep_research.search_system import AdvancedSearchSystem return AdvancedSearchSystem(llm=mock_model, search=mock_search, **kwargs) # =================================================================== # __init__ tests # =================================================================== class TestInit: """Tests for AdvancedSearchSystem.__init__.""" def test_settings_snapshot_defaults_to_empty_dict( self, mock_model, mock_search, mock_deps ): system = _build_system( mock_model, mock_search, mock_deps, settings_snapshot=None ) assert system.settings_snapshot == {} # -- max_iterations ------------------------------------------------- def test_max_iterations_default_is_one( self, mock_model, mock_search, mock_deps ): system = _build_system(mock_model, mock_search, mock_deps) assert system.max_iterations == 1 def test_max_iterations_from_settings_plain_value( self, mock_model, mock_search, mock_deps ): system = _build_system( mock_model, mock_search, mock_deps, settings_snapshot={"search.iterations": 5}, ) assert system.max_iterations == 5 def test_max_iterations_extracts_value_from_dict( self, mock_model, mock_search, mock_deps ): system = _build_system( mock_model, mock_search, mock_deps, settings_snapshot={"search.iterations": {"value": 8}}, ) assert system.max_iterations == 8 def test_max_iterations_explicit_overrides_snapshot( self, mock_model, mock_search, mock_deps ): system = _build_system( mock_model, mock_search, mock_deps, max_iterations=12, settings_snapshot={"search.iterations": {"value": 8}}, ) assert system.max_iterations == 12 # -- questions_per_iteration ---------------------------------------- def test_questions_per_iteration_default_is_three( self, mock_model, mock_search, mock_deps ): system = _build_system(mock_model, mock_search, mock_deps) assert system.questions_per_iteration == 3 def test_questions_per_iteration_from_settings_dict( self, mock_model, mock_search, mock_deps ): system = _build_system( mock_model, mock_search, mock_deps, settings_snapshot={"search.questions_per_iteration": {"value": 7}}, ) assert system.questions_per_iteration == 7 # -- strategy creation via factory ---------------------------------- def test_calls_create_strategy_factory( self, mock_model, mock_search, mock_deps ): _build_system(mock_model, mock_search, mock_deps) mock_deps["create_strategy"].assert_called_once() # -- enhanced-contextual-followup strategy -------------------------- def test_enhanced_contextual_followup_creates_delegate( self, mock_model, mock_search, mock_deps ): with patch(PATCH_ENHANCED_FOLLOWUP) as m_followup: m_followup.return_value = _make_strategy_mock() system = _build_system( mock_model, mock_search, mock_deps, strategy_name="enhanced-contextual-followup", ) # create_strategy should have been called for the delegate mock_deps["create_strategy"].assert_called_once() delegate_kwargs = mock_deps["create_strategy"].call_args[1] assert delegate_kwargs["strategy_name"] == "source-based" # EnhancedContextualFollowUpStrategy should have been instantiated m_followup.assert_called_once() assert system.strategy is m_followup.return_value # -- programmatic_mode warning -------------------------------------- def test_programmatic_mode_logs_warning( self, mock_model, mock_search, mock_deps ): with patch("local_deep_research.search_system.logger") as m_logger: _build_system( mock_model, mock_search, mock_deps, programmatic_mode=True ) m_logger.warning.assert_called_once() assert ( "programmatic mode" in m_logger.warning.call_args[0][0].lower() ) # =================================================================== # set_progress_callback tests # =================================================================== class TestSetProgressCallback: """Tests for set_progress_callback.""" def test_forwards_callback_to_strategy( self, mock_model, mock_search, mock_deps ): system = _build_system(mock_model, mock_search, mock_deps) cb = MagicMock(name="my_callback") system.set_progress_callback(cb) mock_deps["strategy"].set_progress_callback.assert_called_with(cb) # =================================================================== # analyze_topic tests # =================================================================== class TestAnalyzeTopic: """Tests for analyze_topic.""" def test_generates_search_id_when_not_provided( self, mock_model, mock_search, mock_deps ): system = _build_system(mock_model, mock_search, mock_deps) with patch.object(system, "_perform_search", return_value={}) as m_ps: system.analyze_topic("quantum computing") # search_id (2nd positional arg) should be a non-empty string search_id = m_ps.call_args[0][1] assert isinstance(search_id, str) and len(search_id) > 0 def test_passes_explicit_search_id( self, mock_model, mock_search, mock_deps ): system = _build_system(mock_model, mock_search, mock_deps) with patch.object(system, "_perform_search", return_value={}) as m_ps: system.analyze_topic("q", search_id="my-id-99") assert m_ps.call_args[0][1] == "my-id-99" def test_returns_perform_search_result( self, mock_model, mock_search, mock_deps ): system = _build_system(mock_model, mock_search, mock_deps) expected = {"current_knowledge": "deep"} with patch.object(system, "_perform_search", return_value=expected): result = system.analyze_topic("topic") assert result is expected # =================================================================== # _perform_search tests # =================================================================== class TestPerformSearch: """Tests for _perform_search.""" def _run_perform_search( self, mock_model, mock_search, mock_deps, settings=None, query="test q", ): system = _build_system( mock_model, mock_search, mock_deps, settings_snapshot=settings or {}, ) progress_calls = [] system.progress_callback = lambda m, p, md: progress_calls.append( (m, p, md) ) with patch(PATCH_NEWS_CALLBACK): result = system._perform_search( query, "sid-1", True, False, "user1" ) return system, result, progress_calls def test_extracts_llm_provider_from_settings( self, mock_model, mock_search, mock_deps ): settings = {"llm.provider": {"value": "anthropic"}} _, _, calls = self._run_perform_search( mock_model, mock_search, mock_deps, settings=settings ) assert any("anthropic" in str(c) for c in calls) def test_extracts_llm_model_from_settings( self, mock_model, mock_search, mock_deps ): settings = {"llm.model": "claude-3-opus"} _, _, calls = self._run_perform_search( mock_model, mock_search, mock_deps, settings=settings ) assert any("claude-3-opus" in str(c) for c in calls) def test_extracts_search_tool_from_settings( self, mock_model, mock_search, mock_deps ): settings = {"search.tool": {"value": "google"}} _, _, calls = self._run_perform_search( mock_model, mock_search, mock_deps, settings=settings ) assert any("google" in str(c) for c in calls) def test_calls_progress_callback_with_setup_info( self, mock_model, mock_search, mock_deps ): _, _, calls = self._run_perform_search( mock_model, mock_search, mock_deps ) # At least two calls: one for LLM info, one for search tool info assert len(calls) >= 2 phases = [c[2].get("phase") for c in calls] assert "setup" in phases def test_calls_strategy_analyze_topic( self, mock_model, mock_search, mock_deps ): self._run_perform_search( mock_model, mock_search, mock_deps, query="my query" ) mock_deps["strategy"].analyze_topic.assert_called_once_with("my query") def test_updates_questions_by_iteration( self, mock_model, mock_search, mock_deps ): mock_deps["strategy"].questions_by_iteration = ["q1", "q2"] system, result, _ = self._run_perform_search( mock_model, mock_search, mock_deps ) assert system.questions_by_iteration == ["q1", "q2"] assert result["questions_by_iteration"] == ["q1", "q2"] def test_result_contains_search_system( self, mock_model, mock_search, mock_deps ): system, result, _ = self._run_perform_search( mock_model, mock_search, mock_deps ) assert result["search_system"] is system def test_result_contains_all_links_of_system( self, mock_model, mock_search, mock_deps ): mock_deps["strategy"].all_links_of_system = [{"url": "http://a.com"}] _, result, _ = self._run_perform_search( mock_model, mock_search, mock_deps ) assert "all_links_of_system" in result def test_result_contains_query(self, mock_model, mock_search, mock_deps): _, result, _ = self._run_perform_search( mock_model, mock_search, mock_deps, query="deep research" ) assert result["query"] == "deep research" def test_avoids_duplicate_links_when_same_object( self, mock_model, mock_search, mock_deps ): """When strategy.all_links_of_system is the SAME object as system.all_links_of_system, no extend should happen (id check).""" system = _build_system(mock_model, mock_search, mock_deps) # Make the strategy share the exact same list object mock_deps["strategy"].all_links_of_system = system.all_links_of_system system.all_links_of_system.extend( [{"url": "http://a.com"}, {"url": "http://b.com"}] ) system.progress_callback = lambda m, p, md: None with patch(PATCH_NEWS_CALLBACK): system._perform_search("q", "sid", True, False, "u") # If id-check were removed, self-extend would double to 4 assert len(system.all_links_of_system) == 2 def test_extends_links_when_different_objects( self, mock_model, mock_search, mock_deps ): """When strategy.all_links_of_system is a different object, the links should be extended.""" system = _build_system(mock_model, mock_search, mock_deps) strategy_links = [{"url": "http://new.com"}] mock_deps["strategy"].all_links_of_system = strategy_links system.progress_callback = lambda m, p, md: None with patch(PATCH_NEWS_CALLBACK): system._perform_search("q", "sid", True, False, "u") assert {"url": "http://new.com"} in system.all_links_of_system def test_news_callback_error_does_not_break_search( self, mock_model, mock_search, mock_deps ): system = _build_system(mock_model, mock_search, mock_deps) system.progress_callback = lambda m, p, md: None with patch(PATCH_NEWS_CALLBACK, side_effect=Exception("boom")): # Should not raise result = system._perform_search("q", "sid", True, False, "u") assert isinstance(result, dict)