Files
learningcircuit--local-deep…/tests/news/test_topic_generator_coverage.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

377 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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