7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""
|
||
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
|