""" Comprehensive coverage tests for topic_generator.py. Focuses on areas with insufficient coverage in existing test files: - _generate_with_llm prompt construction and truncation logic - _generate_with_llm response cleaning pipeline (non-string filtering, length cap) - _generate_with_llm comma-separated text fallback path - _generate_with_llm close_llm always called (finally block) - generate_topics orchestration with real _validate_topics (no double-mock) - _validate_topics boundary and ordering edge cases """ from unittest.mock import Mock, patch from local_deep_research.news.utils.topic_generator import ( _generate_with_llm, _validate_topics, generate_topics, ) # --------------------------------------------------------------------------- # _generate_with_llm – prompt construction # --------------------------------------------------------------------------- class TestGenerateWithLLMPromptConstruction: """Verify the prompt fed to the LLM is built correctly.""" def _invoke_and_capture_prompt( self, query="q", findings="", category="", max_topics=5 ): """Helper: call _generate_with_llm with a mock LLM and return the prompt string.""" mock_llm = Mock() mock_llm.invoke.return_value = Mock(content='["tag"]') with patch( "local_deep_research.config.llm_config.get_llm", return_value=mock_llm, ): _generate_with_llm(query, findings, category, max_topics) prompt = mock_llm.invoke.call_args[0][0] return prompt def test_prompt_contains_query(self): prompt = self._invoke_and_capture_prompt(query="climate crisis") assert "climate crisis" in prompt def test_query_truncated_at_500(self): long_query = "x" * 600 prompt = self._invoke_and_capture_prompt(query=long_query) assert "x" * 500 in prompt assert "x" * 501 not in prompt def test_query_not_truncated_when_short(self): prompt = self._invoke_and_capture_prompt(query="short") assert "short" in prompt def test_findings_included_when_present(self): prompt = self._invoke_and_capture_prompt(findings="some findings text") assert "some findings text" in prompt def test_findings_truncated_at_1000(self): long_findings = "f" * 1500 prompt = self._invoke_and_capture_prompt(findings=long_findings) assert "f" * 1000 in prompt assert "f" * 1001 not in prompt def test_findings_omitted_when_empty(self): prompt = self._invoke_and_capture_prompt(findings="") assert "Content:" not in prompt def test_category_included_when_present(self): prompt = self._invoke_and_capture_prompt(category="Technology") assert "Category: Technology" in prompt def test_category_omitted_when_empty(self): prompt = self._invoke_and_capture_prompt(category="") assert "Category:" not in prompt def test_max_topics_in_prompt(self): prompt = self._invoke_and_capture_prompt(max_topics=7) assert "7" in prompt # --------------------------------------------------------------------------- # _generate_with_llm – response cleaning # --------------------------------------------------------------------------- class TestGenerateWithLLMResponseCleaning: """Verify the cleaning pipeline inside _generate_with_llm.""" def _run_with_llm_content(self, content, max_topics=5): """Helper: mock LLM returning `content` and return the result list.""" mock_llm = Mock() mock_llm.invoke.return_value = Mock(content=content) with patch( "local_deep_research.config.llm_config.get_llm", return_value=mock_llm, ): return _generate_with_llm("q", "f", "", max_topics) def test_valid_json_array_parsed(self): result = self._run_with_llm_content('["AI", "Climate"]') assert result == ["AI", "Climate"] def test_non_string_items_filtered(self): """Items that are not strings should be removed.""" result = self._run_with_llm_content('[123, "Valid", null, true]') assert result == ["Valid"] def test_empty_string_items_filtered(self): result = self._run_with_llm_content('["", "Valid", " "]') # empty and whitespace-only are filtered (strip then falsy check) assert result == ["Valid"] def test_items_over_30_chars_filtered(self): long = "a" * 31 result = self._run_with_llm_content(f'["{long}", "short"]') assert result == ["short"] def test_exactly_30_char_item_kept(self): item = "a" * 30 result = self._run_with_llm_content(f'["{item}"]') assert result == [item] def test_max_topics_limits_json_result(self): result = self._run_with_llm_content( '["a1", "b2", "c3", "d4", "e5"]', max_topics=2 ) assert len(result) == 2 assert result == ["a1", "b2"] def test_items_are_stripped(self): result = self._run_with_llm_content('[" padded "]') assert result == ["padded"] # --------------------------------------------------------------------------- # _generate_with_llm – comma-separated fallback # --------------------------------------------------------------------------- class TestGenerateWithLLMCommaFallback: """When extract_json returns None but content has commas, split on comma.""" def _run_with_non_json(self, content, max_topics=5): mock_llm = Mock() mock_llm.invoke.return_value = Mock(content=content) with ( patch( "local_deep_research.config.llm_config.get_llm", return_value=mock_llm, ), patch( "local_deep_research.news.utils.topic_generator.extract_json", return_value=None, ), ): return _generate_with_llm("q", "f", "", max_topics) def test_comma_separated_parsed(self): result = self._run_with_non_json("AI, Climate, Economy") assert "AI" in result assert "Climate" in result assert "Economy" in result def test_quotes_stripped_from_comma_items(self): result = self._run_with_non_json('"AI", "Climate"') assert "AI" in result assert "Climate" in result def test_long_items_filtered_in_comma_path(self): long = "z" * 31 result = self._run_with_non_json(f"valid, {long}") assert "valid" in result assert long not in result def test_empty_items_filtered_in_comma_path(self): result = self._run_with_non_json("AI, , , Climate") assert "" not in result assert len(result) == 2 def test_max_topics_applied_in_comma_path(self): result = self._run_with_non_json("aa, bb, cc, dd, ee", max_topics=2) assert len(result) == 2 def test_no_comma_returns_empty_list(self): """If content has no comma and JSON parsing failed, function returns [].""" mock_llm = Mock() mock_llm.invoke.return_value = Mock(content="just plain text") with ( patch( "local_deep_research.config.llm_config.get_llm", return_value=mock_llm, ), patch( "local_deep_research.news.utils.topic_generator.extract_json", return_value=None, ), ): result = _generate_with_llm("q", "f", "", 5) # Function falls through the try block without explicit return, # then the outer except catches the implicit None and returns [] assert result == [] # --------------------------------------------------------------------------- # _generate_with_llm – error handling and resource cleanup # --------------------------------------------------------------------------- class TestGenerateWithLLMErrorHandling: """Verify error handling and LLM cleanup.""" def test_returns_empty_list_on_get_llm_failure(self): with patch( "local_deep_research.config.llm_config.get_llm", side_effect=Exception("no LLM"), ): result = _generate_with_llm("q", "f", "", 5) assert result == [] def test_returns_empty_list_on_invoke_failure(self): mock_llm = Mock() mock_llm.invoke.side_effect = RuntimeError("invoke boom") with patch( "local_deep_research.config.llm_config.get_llm", return_value=mock_llm, ): result = _generate_with_llm("q", "f", "", 5) assert result == [] # close should still be called (finally block) mock_llm.close.assert_called_once() def test_close_called_on_success(self): mock_llm = Mock() mock_llm.invoke.return_value = Mock(content='["tag"]') with patch( "local_deep_research.config.llm_config.get_llm", return_value=mock_llm, ): _generate_with_llm("q", "f", "", 5) mock_llm.close.assert_called_once() # --------------------------------------------------------------------------- # generate_topics – orchestration (uses real _validate_topics) # --------------------------------------------------------------------------- class TestGenerateTopicsOrchestration: """Test generate_topics with real _validate_topics (only mock _generate_with_llm).""" @patch("local_deep_research.news.utils.topic_generator._generate_with_llm") def test_llm_topics_validated_and_lowercased(self, mock_llm): mock_llm.return_value = ["AI", "Climate Change", "AI"] result = generate_topics("query") # Duplicates removed, lowercased assert result == ["ai", "climate change"] @patch("local_deep_research.news.utils.topic_generator._generate_with_llm") def test_llm_empty_gives_failure_marker(self, mock_llm): mock_llm.return_value = [] result = generate_topics("query") # "[Topic generation failed]" goes through _validate_topics # It is 25 chars, >= 2, so it passes through as lowercase assert result == ["[topic generation failed]"] @patch("local_deep_research.news.utils.topic_generator._generate_with_llm") def test_llm_returns_all_invalid_gives_no_valid(self, mock_llm): mock_llm.return_value = ["a", ""] # all too short or empty result = generate_topics("query") assert result == ["[No valid topics]"] @patch("local_deep_research.news.utils.topic_generator._generate_with_llm") def test_max_topics_forwarded_to_llm(self, mock_llm): mock_llm.return_value = ["aa", "bb", "cc"] generate_topics("q", max_topics=7) assert mock_llm.call_args[0][3] == 7 @patch("local_deep_research.news.utils.topic_generator._generate_with_llm") def test_category_forwarded_to_llm(self, mock_llm): mock_llm.return_value = ["tag"] generate_topics("q", category="Sports") assert mock_llm.call_args[0][2] == "Sports" @patch("local_deep_research.news.utils.topic_generator._generate_with_llm") def test_findings_forwarded_to_llm(self, mock_llm): mock_llm.return_value = ["tag"] generate_topics("q", findings="some findings") assert mock_llm.call_args[0][1] == "some findings" @patch("local_deep_research.news.utils.topic_generator._generate_with_llm") def test_default_parameters(self, mock_llm): mock_llm.return_value = [] generate_topics("q") # (query, findings, category, max_topics, settings_snapshot) args = mock_llm.call_args[0] assert args == ("q", "", "", 5, None) # --------------------------------------------------------------------------- # _validate_topics – additional boundary / ordering tests # --------------------------------------------------------------------------- class TestValidateTopicsAdditional: """Cover edge cases not well-tested elsewhere.""" def test_whitespace_after_strip_becomes_too_short(self): """A topic that is long enough pre-strip but too short after.""" result = _validate_topics([" x "], max_topics=5) # "x" is 1 char after strip -> filtered assert result == ["[No valid topics]"] def test_dedup_happens_after_strip(self): """' AI ' and 'AI' should be treated as duplicates after stripping.""" result = _validate_topics([" AI ", "AI"], max_topics=5) assert result == ["ai"] def test_max_topics_zero_still_returns_one(self): """max_topics=0 is a degenerate case: the >= check means one topic gets appended before the break triggers, so we get exactly one.""" result = _validate_topics(["valid", "topic"], max_topics=0) assert result == ["valid"] def test_large_number_of_topics(self): topics = [f"topic{i:04d}" for i in range(200)] result = _validate_topics(topics, max_topics=10) assert len(result) == 10 assert result[0] == "topic0000" def test_preserves_internal_whitespace(self): """Internal spaces in multi-word topics should be preserved.""" result = _validate_topics(["climate change"], max_topics=5) assert result == ["climate change"] def test_tab_and_newline_stripped(self): result = _validate_topics(["\tAI\n"], max_topics=5) assert result == ["ai"] def test_failure_marker_from_llm_passes_through(self): """The '[Topic generation failed]' marker should survive validation.""" result = _validate_topics(["[Topic generation failed]"], max_topics=5) assert result == ["[topic generation failed]"] def test_special_chars_preserved(self): result = _validate_topics(["COVID-19", "AI/ML"], max_topics=5) assert "covid-19" in result assert "ai/ml" in result def test_unicode_topics_preserved(self): result = _validate_topics(["klima", "umwelt"], max_topics=5) assert result == ["klima", "umwelt"] def test_exactly_boundary_lengths(self): """2-char kept, 1-char dropped, 30-char kept, 31-char dropped.""" result = _validate_topics( ["ab", "a", "c" * 30, "d" * 31], max_topics=10 ) assert "ab" in result assert "a" not in result assert "c" * 30 in result assert "d" * 31 not in result