""" Tests for news/utils/topic_generator.py Tests cover: - Topic generation with mocked LLM - Topic validation and cleaning - Edge cases: empty input, duplicates, invalid topics """ from unittest.mock import patch, MagicMock class TestValidateTopics: """Tests for the _validate_topics function.""" def test_removes_empty_topics(self): """Test that empty topics are removed.""" from local_deep_research.news.utils.topic_generator import ( _validate_topics, ) topics = ["valid", "", " ", "another", None] # Filter out None before passing topics = [t for t in topics if t is not None] result = _validate_topics(topics, 5) assert "valid" in result assert "another" in result assert "" not in result def test_removes_short_topics(self): """Test that topics shorter than 2 chars are removed.""" from local_deep_research.news.utils.topic_generator import ( _validate_topics, ) topics = ["a", "ab", "abc", "valid topic"] result = _validate_topics(topics, 5) assert "a" not in result assert "ab" in result assert "abc" in result assert "valid topic" in result def test_removes_long_topics(self): """Test that topics longer than 30 chars are removed.""" from local_deep_research.news.utils.topic_generator import ( _validate_topics, ) long_topic = "x" * 31 short_topic = "x" * 30 topics = [long_topic, short_topic, "normal"] result = _validate_topics(topics, 5) assert long_topic not in result assert "x" * 30 in result assert "normal" in result def test_removes_case_insensitive_duplicates(self): """Test that duplicates are removed case-insensitively.""" from local_deep_research.news.utils.topic_generator import ( _validate_topics, ) topics = ["Technology", "technology", "TECHNOLOGY", "Other"] result = _validate_topics(topics, 5) # Should only have one version of technology tech_count = sum(1 for t in result if "technolog" in t.lower()) assert tech_count == 1 assert "other" in result def test_respects_max_topics_limit(self): """Test that max_topics limit is enforced.""" from local_deep_research.news.utils.topic_generator import ( _validate_topics, ) topics = ["one", "two", "three", "four", "five", "six"] result = _validate_topics(topics, 3) assert len(result) <= 3 def test_converts_to_lowercase(self): """Test that topics are converted to lowercase.""" from local_deep_research.news.utils.topic_generator import ( _validate_topics, ) topics = ["UPPERCASE", "MixedCase", "lowercase"] result = _validate_topics(topics, 5) for topic in result: assert topic == topic.lower() def test_strips_whitespace(self): """Test that whitespace is stripped from topics.""" from local_deep_research.news.utils.topic_generator import ( _validate_topics, ) topics = [" spaced ", "\ttabbed\t", "normal"] result = _validate_topics(topics, 5) for topic in result: assert topic == topic.strip() def test_empty_input_returns_no_valid_topics_message(self): """Test that empty input returns failure indicator.""" from local_deep_research.news.utils.topic_generator import ( _validate_topics, ) result = _validate_topics([], 5) assert result == ["[No valid topics]"] def test_all_invalid_returns_failure_message(self): """Test that all invalid topics returns failure indicator.""" from local_deep_research.news.utils.topic_generator import ( _validate_topics, ) # All topics are too short topics = ["a", "b", "c"] result = _validate_topics(topics, 5) assert result == ["[No valid topics]"] class TestGenerateTopics: """Tests for the generate_topics function.""" @patch("local_deep_research.news.utils.topic_generator._generate_with_llm") def test_returns_llm_topics_when_successful(self, mock_llm_gen): """Test that LLM topics are returned when generation succeeds.""" from local_deep_research.news.utils.topic_generator import ( generate_topics, ) mock_llm_gen.return_value = ["tech", "innovation", "startup"] result = generate_topics( query="tech news", findings="A startup launched a new product" ) assert "tech" in result assert "innovation" in result assert "startup" in result @patch("local_deep_research.news.utils.topic_generator._generate_with_llm") def test_returns_failure_message_when_llm_fails(self, mock_llm_gen): """Test that failure message is returned when LLM fails.""" from local_deep_research.news.utils.topic_generator import ( generate_topics, ) mock_llm_gen.return_value = [] result = generate_topics(query="test", findings="test") assert "[topic generation failed]" in result class TestGenerateWithLLM: """Tests for the _generate_with_llm function.""" @patch("local_deep_research.config.llm_config.get_llm") def test_parses_json_array_response(self, mock_get_llm): """Test parsing of JSON array from LLM.""" from local_deep_research.news.utils.topic_generator import ( _generate_with_llm, ) mock_llm = MagicMock() mock_response = MagicMock() mock_response.content = '["topic1", "topic2", "topic3"]' mock_llm.invoke.return_value = mock_response mock_get_llm.return_value = mock_llm result = _generate_with_llm("query", "findings", "category", 5) assert "topic1" in result assert "topic2" in result assert "topic3" in result @patch("local_deep_research.config.llm_config.get_llm") def test_handles_json_with_markdown_code_block(self, mock_get_llm): """Test handling of JSON wrapped in markdown code block.""" from local_deep_research.news.utils.topic_generator import ( _generate_with_llm, ) mock_llm = MagicMock() mock_response = MagicMock() mock_response.content = '```json\n["topic1", "topic2"]\n```' mock_llm.invoke.return_value = mock_response mock_get_llm.return_value = mock_llm result = _generate_with_llm("query", "findings", "category", 5) assert "topic1" in result assert "topic2" in result @patch("local_deep_research.config.llm_config.get_llm") def test_handles_comma_separated_fallback(self, mock_get_llm): """Test fallback parsing of comma-separated topics.""" from local_deep_research.news.utils.topic_generator import ( _generate_with_llm, ) mock_llm = MagicMock() mock_response = MagicMock() mock_response.content = "topic one, topic two, topic three" mock_llm.invoke.return_value = mock_response mock_get_llm.return_value = mock_llm result = _generate_with_llm("query", "findings", "category", 5) assert "topic one" in result assert "topic two" in result @patch("local_deep_research.config.llm_config.get_llm") def test_respects_max_topics(self, mock_get_llm): """Test that max_topics limit is respected.""" from local_deep_research.news.utils.topic_generator import ( _generate_with_llm, ) mock_llm = MagicMock() mock_response = MagicMock() mock_response.content = '["t1", "t2", "t3", "t4", "t5", "t6", "t7"]' mock_llm.invoke.return_value = mock_response mock_get_llm.return_value = mock_llm result = _generate_with_llm("query", "findings", "category", 3) assert len(result) <= 3 @patch("local_deep_research.config.llm_config.get_llm") def test_filters_long_topics(self, mock_get_llm): """Test that topics longer than 30 chars are filtered.""" from local_deep_research.news.utils.topic_generator import ( _generate_with_llm, ) long_topic = "x" * 35 mock_llm = MagicMock() mock_response = MagicMock() mock_response.content = f'["short", "{long_topic}"]' mock_llm.invoke.return_value = mock_response mock_get_llm.return_value = mock_llm result = _generate_with_llm("query", "findings", "category", 5) assert "short" in result assert long_topic not in result @patch("local_deep_research.config.llm_config.get_llm") def test_handles_llm_exception(self, mock_get_llm): """Test graceful handling of LLM exceptions.""" from local_deep_research.news.utils.topic_generator import ( _generate_with_llm, ) mock_get_llm.side_effect = Exception("LLM error") result = _generate_with_llm("query", "findings", "category", 5) assert result == [] @patch("local_deep_research.config.llm_config.get_llm") def test_uses_medium_temperature(self, mock_get_llm): """Test that medium temperature is used for topic diversity.""" from local_deep_research.news.utils.topic_generator import ( _generate_with_llm, ) mock_llm = MagicMock() mock_response = MagicMock() mock_response.content = '["topic"]' mock_llm.invoke.return_value = mock_response mock_get_llm.return_value = mock_llm _generate_with_llm("query", "findings", "category", 5) mock_get_llm.assert_called_once_with( temperature=0.5, settings_snapshot=None ) @patch("local_deep_research.config.llm_config.get_llm") def test_truncates_long_query_in_prompt(self, mock_get_llm): """Test that long queries are truncated in prompt.""" from local_deep_research.news.utils.topic_generator import ( _generate_with_llm, ) mock_llm = MagicMock() mock_response = MagicMock() mock_response.content = '["topic"]' mock_llm.invoke.return_value = mock_response mock_get_llm.return_value = mock_llm long_query = "x" * 1000 _generate_with_llm(long_query, "findings", "category", 5) # The prompt should be invoked with truncated query call_args = mock_llm.invoke.call_args prompt = call_args[0][0] # Query should be truncated to 500 chars assert "x" * 501 not in prompt