""" Gap tests for search_system.AdvancedSearchSystem. Focuses on testable pure logic: - Settings extraction for max_iterations / questions_per_iteration (dict-with-value vs plain value vs missing -> default) - _perform_search settings extraction for llm_provider, llm_model, search_tool - close() resource cleanup - set_progress_callback propagation - analyze_topic generates search_id when not provided - all_links_of_system dedup (same-object identity check) """ from unittest.mock import Mock, patch import pytest # --------------------------------------------------------------------------- # Factory fixture for AdvancedSearchSystem # --------------------------------------------------------------------------- @pytest.fixture def make_system(): """Factory fixture returning a function that builds an AdvancedSearchSystem with a mocked strategy. Returns ``(system, mock_strategy)`` so tests can inspect or mutate the strategy mock and assert on the system. """ def _factory( settings_snapshot=None, strategy_name="source-based", **kwargs ): mock_llm = Mock() mock_search = Mock() mock_strategy = Mock() mock_strategy.questions_by_iteration = {} mock_strategy.all_links_of_system = [] mock_strategy.set_progress_callback = Mock() mock_strategy.analyze_topic = Mock( return_value={ "findings": [], "current_knowledge": "", } ) with patch( "local_deep_research.search_system_factory.create_strategy", return_value=mock_strategy, ): from local_deep_research.search_system import AdvancedSearchSystem system = AdvancedSearchSystem( llm=mock_llm, search=mock_search, strategy_name=strategy_name, settings_snapshot=settings_snapshot, **kwargs, ) return system, mock_strategy return _factory # --------------------------------------------------------------------------- # __init__ -- max_iterations extraction # --------------------------------------------------------------------------- class TestMaxIterationsExtraction: """AdvancedSearchSystem.__init__ reads max_iterations from settings.""" def test_explicit_value_takes_precedence(self, make_system): system, _ = make_system( settings_snapshot={"search.iterations": {"value": 10}}, max_iterations=5, ) assert system.max_iterations == 5 def test_dict_with_value_key(self, make_system): system, _ = make_system( settings_snapshot={"search.iterations": {"value": 7}}, ) assert system.max_iterations == 7 def test_plain_value(self, make_system): system, _ = make_system( settings_snapshot={"search.iterations": 4}, ) assert system.max_iterations == 4 def test_missing_defaults_to_1(self, make_system): system, _ = make_system(settings_snapshot={}) assert system.max_iterations == 1 def test_none_snapshot_defaults_to_1(self, make_system): system, _ = make_system(settings_snapshot=None) assert system.max_iterations == 1 # --------------------------------------------------------------------------- # __init__ -- questions_per_iteration extraction # --------------------------------------------------------------------------- class TestQuestionsPerIterationExtraction: """AdvancedSearchSystem.__init__ reads questions_per_iteration from settings.""" def test_explicit_value_takes_precedence(self, make_system): system, _ = make_system( settings_snapshot={"search.questions_per_iteration": {"value": 10}}, questions_per_iteration=2, ) assert system.questions_per_iteration == 2 def test_dict_with_value_key(self, make_system): system, _ = make_system( settings_snapshot={"search.questions_per_iteration": {"value": 5}}, ) assert system.questions_per_iteration == 5 def test_plain_value(self, make_system): system, _ = make_system( settings_snapshot={"search.questions_per_iteration": 8}, ) assert system.questions_per_iteration == 8 def test_missing_defaults_to_3(self, make_system): system, _ = make_system(settings_snapshot={}) assert system.questions_per_iteration == 3 # --------------------------------------------------------------------------- # _perform_search -- settings extraction for progress messages # --------------------------------------------------------------------------- class TestPerformSearchSettingsExtraction: """_perform_search extracts provider/model/tool from settings snapshot.""" def _run_perform_search(self, system): """Run _perform_search capturing progress callback calls.""" calls = [] system.progress_callback = lambda msg, pct, meta: calls.append( (msg, pct, meta) ) with patch( "local_deep_research.news.core.search_integration.NewsSearchCallback", ): system._perform_search("q", "id1", True, False, "user1") return calls def test_extracts_from_dict_with_value(self, make_system): system, _ = make_system( settings_snapshot={ "llm.provider": {"value": "openai"}, "llm.model": {"value": "gpt-4"}, "search.tool": {"value": "google"}, }, ) calls = self._run_perform_search(system) assert any("openai" in c[0] or "gpt-4" in c[0] for c in calls) def test_extracts_plain_values(self, make_system): system, _ = make_system( settings_snapshot={ "llm.provider": "anthropic", "llm.model": "claude", "search.tool": "searxng", }, ) calls = self._run_perform_search(system) assert any("anthropic" in c[0] or "claude" in c[0] for c in calls) def test_missing_settings_use_unknown(self, make_system): system, _ = make_system(settings_snapshot={}) calls = self._run_perform_search(system) assert any("unknown" in c[0] for c in calls) def test_search_tool_default_from_dict(self, make_system): """search.tool dict without 'value' key defaults to 'searxng'.""" system, _ = make_system( settings_snapshot={"search.tool": {"other": "x"}}, ) calls = self._run_perform_search(system) # Should use the .get("value", "searxng") default assert any("searxng" in c[0] for c in calls) # --------------------------------------------------------------------------- # all_links_of_system -- identity-based dedup # --------------------------------------------------------------------------- class TestAllLinksDedup: """_perform_search avoids extending all_links when objects are same.""" def test_same_object_no_duplication(self, make_system): """When strategy.all_links_of_system IS system.all_links_of_system, no extension should happen (prevents issue #301).""" system, mock_strategy = make_system(settings_snapshot={}) # Make them the same object mock_strategy.all_links_of_system = system.all_links_of_system system.all_links_of_system.append({"link": "https://a.com"}) with patch( "local_deep_research.news.core.search_integration.NewsSearchCallback", ): system._perform_search("q", "id1", True, False, "user1") # Should NOT be doubled assert len(system.all_links_of_system) == 1 def test_different_objects_extends(self, make_system): """When they are different objects, links should be extended.""" system, mock_strategy = make_system(settings_snapshot={}) mock_strategy.all_links_of_system = [{"link": "https://b.com"}] with patch( "local_deep_research.news.core.search_integration.NewsSearchCallback", ): system._perform_search("q", "id1", True, False, "user1") assert {"link": "https://b.com"} in system.all_links_of_system # --------------------------------------------------------------------------- # close() -- resource cleanup # --------------------------------------------------------------------------- class TestClose: """AdvancedSearchSystem.close cascades to strategy.""" def test_close_calls_safe_close_on_strategy(self, make_system): system, mock_strategy = make_system(settings_snapshot={}) with patch( "local_deep_research.utilities.resource_utils.safe_close" ) as mock_safe_close: system.close() mock_safe_close.assert_called_once_with( mock_strategy, "search strategy" ) def test_close_without_strategy_attribute(self, make_system): """close() should not raise if strategy attribute is missing.""" system, _ = make_system(settings_snapshot={}) del system.strategy with patch( "local_deep_research.utilities.resource_utils.safe_close" ) as mock_safe_close: system.close() # should not raise mock_safe_close.assert_not_called() # --------------------------------------------------------------------------- # set_progress_callback # --------------------------------------------------------------------------- class TestSetProgressCallback: def test_callback_set_on_system_and_strategy(self, make_system): system, mock_strategy = make_system(settings_snapshot={}) cb = Mock() system.set_progress_callback(cb) assert system.progress_callback is cb mock_strategy.set_progress_callback.assert_called_with(cb) # --------------------------------------------------------------------------- # analyze_topic -- search_id auto-generation # --------------------------------------------------------------------------- class TestAnalyzeTopicSearchId: def test_generates_uuid_when_search_id_is_none(self, make_system): system, _ = make_system(settings_snapshot={}) with patch.object( system, "_perform_search", return_value={} ) as mock_ps: system.analyze_topic("test query") args = mock_ps.call_args search_id = args[0][1] # second positional arg # Should be a valid UUID string (36 chars with hyphens) assert len(search_id) == 36 assert search_id.count("-") == 4 def test_uses_provided_search_id(self, make_system): system, _ = make_system(settings_snapshot={}) with patch.object( system, "_perform_search", return_value={} ) as mock_ps: system.analyze_topic("test query", search_id="custom-id") args = mock_ps.call_args search_id = args[0][1] assert search_id == "custom-id" # --------------------------------------------------------------------------- # programmatic_mode flag # --------------------------------------------------------------------------- class TestProgrammaticMode: def test_programmatic_mode_stored(self, make_system): system, _ = make_system(settings_snapshot={}, programmatic_mode=True) assert system.programmatic_mode is True def test_non_programmatic_mode_default(self, make_system): system, _ = make_system(settings_snapshot={}) assert system.programmatic_mode is False