""" Comprehensive pytest tests for NewsAnalyzer pure logic helpers. Focuses on edge cases and scenarios not covered by existing test files: - _validate_news_item: truthy/falsy edge cases, numeric values, type variations - _count_categories: case sensitivity, special characters, None category value - _summarize_impact: float scores, extreme values, mixed missing/present scores - _prepare_snippets: snippet vs content precedence, multiple results ordering, edge-length strings (exactly 200 chars), results with no recognized fields - _empty_analysis: structural guarantees, timestamp format - close(): ownership semantics - analyze_news: exception path returns empty analysis """ import json from datetime import datetime from unittest.mock import Mock, patch import pytest from local_deep_research.news.core.news_analyzer import NewsAnalyzer # --------------------------------------------------------------------------- # Fixture: create analyzer without hitting real get_llm # --------------------------------------------------------------------------- @pytest.fixture def analyzer(): """NewsAnalyzer with a no-op mock LLM (does not own it).""" mock_llm = Mock() return NewsAnalyzer(llm_client=mock_llm) @pytest.fixture def analyzer_no_llm(): """NewsAnalyzer where llm_client is explicitly None.""" na = NewsAnalyzer(llm_client=Mock()) na.llm_client = None return na # =========================================================================== # _validate_news_item – edge cases beyond empty/missing # =========================================================================== class TestValidateNewsItemEdgeCases: """Edge-case validation for _validate_news_item.""" def test_integer_headline_truthy(self, analyzer): """Non-zero int is truthy -> should pass the `and item[field]` check.""" assert analyzer._validate_news_item({"headline": 42, "summary": "ok"}) def test_integer_zero_headline_falsy(self, analyzer): """0 is falsy -> should fail validation.""" assert not analyzer._validate_news_item( {"headline": 0, "summary": "ok"} ) def test_whitespace_only_headline_passes(self, analyzer): """Whitespace-only string is truthy (non-empty).""" assert analyzer._validate_news_item( {"headline": " ", "summary": "text"} ) def test_whitespace_only_summary_passes(self, analyzer): assert analyzer._validate_news_item({"headline": "h", "summary": " "}) def test_list_headline_truthy(self, analyzer): """A non-empty list is truthy.""" assert analyzer._validate_news_item( {"headline": ["x"], "summary": "ok"} ) def test_empty_list_headline_falsy(self, analyzer): """Empty list is falsy.""" assert not analyzer._validate_news_item( {"headline": [], "summary": "ok"} ) def test_false_boolean_headline(self, analyzer): assert not analyzer._validate_news_item( {"headline": False, "summary": "ok"} ) def test_true_boolean_headline(self, analyzer): assert analyzer._validate_news_item({"headline": True, "summary": "ok"}) def test_both_none(self, analyzer): assert not analyzer._validate_news_item( {"headline": None, "summary": None} ) def test_only_extra_fields_no_required(self, analyzer): assert not analyzer._validate_news_item( {"category": "Tech", "impact_score": 9, "url": "http://x.com"} ) # =========================================================================== # _count_categories – edge cases # =========================================================================== class TestCountCategoriesEdgeCases: """Edge cases for _count_categories.""" def test_none_category_value_treated_as_none_key(self, analyzer): """If category key exists but value is None, .get returns None (not 'Other').""" items = [{"category": None}] result = analyzer._count_categories(items) # .get("category", "Other") returns None because key exists assert None in result assert result[None] == 1 def test_empty_string_category_is_kept(self, analyzer): """Empty-string category is not replaced by 'Other'.""" items = [{"category": ""}] result = analyzer._count_categories(items) assert "" in result assert result[""] == 1 def test_case_sensitive_categories(self, analyzer): """'Tech' and 'tech' are counted separately.""" items = [{"category": "Tech"}, {"category": "tech"}] result = analyzer._count_categories(items) assert result == {"Tech": 1, "tech": 1} def test_single_item_many_categories_single_count(self, analyzer): items = [{"category": f"cat_{i}"} for i in range(50)] result = analyzer._count_categories(items) assert len(result) == 50 assert all(v == 1 for v in result.values()) def test_special_characters_in_category(self, analyzer): items = [ {"category": "AI & ML"}, {"category": "AI & ML"}, {"category": "U.S. Politics"}, ] result = analyzer._count_categories(items) assert result["AI & ML"] == 2 assert result["U.S. Politics"] == 1 def test_unicode_categories(self, analyzer): items = [{"category": "Politique"}, {"category": "Politique"}] result = analyzer._count_categories(items) assert result["Politique"] == 2 # =========================================================================== # _summarize_impact – edge cases # =========================================================================== class TestSummarizeImpactEdgeCases: """Edge cases for _summarize_impact.""" def test_empty_returns_only_average_and_high_count(self, analyzer): """Empty list returns exactly {average: 0, high_impact_count: 0} with no max/min.""" result = analyzer._summarize_impact([]) assert result == {"average": 0, "high_impact_count": 0} assert "max" not in result assert "min" not in result def test_all_default_scores(self, analyzer): """Items without impact_score all default to 5.""" items = [{}, {}, {}] result = analyzer._summarize_impact(items) assert result["average"] == 5.0 assert result["max"] == 5 assert result["min"] == 5 assert result["high_impact_count"] == 0 def test_float_impact_scores(self, analyzer): """Float scores are handled (sum/len works on floats).""" items = [{"impact_score": 7.5}, {"impact_score": 8.5}] result = analyzer._summarize_impact(items) assert result["average"] == 8.0 assert result["max"] == 8.5 assert result["min"] == 7.5 # 8.5 >= 8 -> 1 high impact assert result["high_impact_count"] == 1 def test_boundary_score_exactly_8(self, analyzer): """Score of exactly 8 counts as high impact.""" items = [{"impact_score": 8}] result = analyzer._summarize_impact(items) assert result["high_impact_count"] == 1 def test_boundary_score_just_below_8(self, analyzer): """Score of 7.99 does not count as high impact.""" items = [{"impact_score": 7.99}] result = analyzer._summarize_impact(items) assert result["high_impact_count"] == 0 def test_very_large_score(self, analyzer): items = [{"impact_score": 1000}] result = analyzer._summarize_impact(items) assert result["max"] == 1000 assert result["high_impact_count"] == 1 def test_negative_score(self, analyzer): items = [{"impact_score": -3}] result = analyzer._summarize_impact(items) assert result["average"] == -3 assert result["min"] == -3 assert result["high_impact_count"] == 0 def test_zero_score(self, analyzer): items = [{"impact_score": 0}] result = analyzer._summarize_impact(items) assert result["average"] == 0 assert result["high_impact_count"] == 0 def test_mixed_present_and_missing_scores(self, analyzer): """Mix of items with and without impact_score.""" items = [ {"impact_score": 10}, {}, # defaults to 5 {"impact_score": 3}, ] result = analyzer._summarize_impact(items) assert result["average"] == 6.0 # (10+5+3)/3 assert result["max"] == 10 assert result["min"] == 3 assert result["high_impact_count"] == 1 # only 10 def test_single_high_impact(self, analyzer): items = [{"impact_score": 9}] result = analyzer._summarize_impact(items) assert result["average"] == 9 assert result["high_impact_count"] == 1 assert result["max"] == 9 assert result["min"] == 9 # =========================================================================== # _prepare_snippets – edge cases # =========================================================================== class TestPrepareSnippetsEdgeCases: """Edge cases for _prepare_snippets.""" def test_empty_list_returns_empty_string(self, analyzer): assert analyzer._prepare_snippets([]) == "" def test_snippet_exactly_200_chars(self, analyzer): """Snippet of exactly 200 chars is included in full (plus '...').""" text = "A" * 200 result = analyzer._prepare_snippets([{"snippet": text}]) assert "A" * 200 + "..." in result def test_snippet_201_chars_truncated(self, analyzer): text = "B" * 201 result = analyzer._prepare_snippets([{"snippet": text}]) assert "B" * 200 in result assert "B" * 201 not in result def test_content_exactly_200_chars(self, analyzer): text = "C" * 200 result = analyzer._prepare_snippets([{"content": text}]) assert "C" * 200 + "..." in result def test_snippet_takes_precedence_over_content(self, analyzer): """When both snippet and content exist, snippet is used.""" result = analyzer._prepare_snippets( [{"snippet": "SNIPPET_TEXT", "content": "CONTENT_TEXT"}] ) assert "SNIPPET_TEXT" in result assert "CONTENT_TEXT" not in result def test_content_used_when_snippet_missing(self, analyzer): result = analyzer._prepare_snippets([{"content": "CONTENT_ONLY"}]) assert "Content: CONTENT_ONLY" in result def test_content_used_when_snippet_empty_string(self, analyzer): """Empty-string snippet is falsy, so content should be used.""" result = analyzer._prepare_snippets( [{"snippet": "", "content": "FALLBACK"}] ) assert "Content: FALLBACK" in result assert "Snippet:" not in result def test_no_recognized_fields(self, analyzer): """Result with no title/url/snippet/content produces just the number prefix.""" result = analyzer._prepare_snippets([{"random_key": "value"}]) assert result.strip() == "[1]" def test_multiple_results_numbered_sequentially(self, analyzer): results = [{"title": f"T{i}"} for i in range(5)] output = analyzer._prepare_snippets(results) for i in range(1, 6): assert f"[{i}]" in output def test_title_url_snippet_all_present(self, analyzer): result = analyzer._prepare_snippets( [{"title": "Headline", "url": "http://x.com", "snippet": "Blurb"}] ) assert "Title: Headline" in result assert "URL: http://x.com" in result assert "Snippet: Blurb" in result def test_newlines_separate_results(self, analyzer): results = [{"title": "A"}, {"title": "B"}] output = analyzer._prepare_snippets(results) # There should be at least a blank line between entries due to # internal newlines in each snippet block joined by \n assert len(output.split("\n\n")) >= 2 assert "[1]" in output assert "[2]" in output def test_empty_title_skipped(self, analyzer): """Empty-string title is falsy, so Title: line should not appear.""" result = analyzer._prepare_snippets([{"title": ""}]) assert "Title:" not in result def test_empty_url_skipped(self, analyzer): result = analyzer._prepare_snippets([{"url": ""}]) assert "URL:" not in result # =========================================================================== # _empty_analysis – structural guarantees # =========================================================================== class TestEmptyAnalysisStructure: """Structural tests for _empty_analysis.""" def test_returns_dict(self, analyzer): result = analyzer._empty_analysis() assert isinstance(result, dict) def test_all_expected_keys(self, analyzer): result = analyzer._empty_analysis() expected = { "items", "item_count", "big_picture", "watch_for", "patterns", "topics", "categories", "impact_summary", "timestamp", } assert set(result.keys()) == expected def test_items_is_list(self, analyzer): assert isinstance(analyzer._empty_analysis()["items"], list) def test_watch_for_is_list(self, analyzer): assert isinstance(analyzer._empty_analysis()["watch_for"], list) def test_topics_is_list(self, analyzer): assert isinstance(analyzer._empty_analysis()["topics"], list) def test_categories_is_dict(self, analyzer): assert isinstance(analyzer._empty_analysis()["categories"], dict) def test_impact_summary_is_dict(self, analyzer): assert isinstance(analyzer._empty_analysis()["impact_summary"], dict) def test_timestamp_is_valid_iso(self, analyzer): ts = analyzer._empty_analysis()["timestamp"] parsed = datetime.fromisoformat(ts) assert parsed.tzinfo is not None # should be timezone-aware def test_impact_summary_keys(self, analyzer): summary = analyzer._empty_analysis()["impact_summary"] assert "average" in summary assert "high_impact_count" in summary def test_two_calls_produce_different_timestamps(self, analyzer): """Timestamps should reflect the call time (not cached).""" t1 = analyzer._empty_analysis()["timestamp"] t2 = analyzer._empty_analysis()["timestamp"] # They may be equal if called within the same microsecond; # just verify both parse correctly datetime.fromisoformat(t1) datetime.fromisoformat(t2) # =========================================================================== # close() – ownership semantics # =========================================================================== class TestClose: """Tests for close() ownership logic.""" def test_close_does_not_close_external_llm(self): """When llm_client is provided externally, close() should NOT close it.""" mock_llm = Mock() na = NewsAnalyzer(llm_client=mock_llm) assert na._owns_llm is False na.close() mock_llm.close.assert_not_called() def test_close_closes_owned_llm(self): """When no llm_client is provided, NewsAnalyzer creates one and owns it.""" with patch( "local_deep_research.news.core.news_analyzer.get_llm" ) as mock_get: mock_llm = Mock() mock_get.return_value = mock_llm na = NewsAnalyzer() assert na._owns_llm is True na.close() mock_llm.close.assert_called_once() # =========================================================================== # analyze_news – exception handling path # =========================================================================== class TestAnalyzeNewsExceptionPath: """Tests that analyze_news returns _empty_analysis on internal errors.""" def test_exception_in_extract_returns_empty(self): """If extract_news_items raises, analyze_news catches and returns empty.""" mock_llm = Mock() na = NewsAnalyzer(llm_client=mock_llm) with patch.object( na, "extract_news_items", side_effect=RuntimeError("boom") ): result = na.analyze_news([{"content": "data"}]) assert result["items"] == [] assert result["item_count"] == 0 def test_empty_search_results_skips_llm(self): """Empty input should return empty analysis without invoking LLM.""" mock_llm = Mock() na = NewsAnalyzer(llm_client=mock_llm) result = na.analyze_news([]) mock_llm.invoke.assert_not_called() assert result["item_count"] == 0 # =========================================================================== # extract_news_items – ID generation and filtering # =========================================================================== class TestExtractNewsItemsIDsAndFiltering: """Tests for extract_news_items ID assignment and item filtering.""" def test_each_item_gets_unique_id(self): mock_llm = Mock() items = [ {"headline": f"News {i}", "summary": f"Summary {i}"} for i in range(3) ] mock_llm.invoke.return_value = Mock(content=json.dumps(items)) na = NewsAnalyzer(llm_client=mock_llm) result = na.extract_news_items([{"title": "source"}]) ids = [item["id"] for item in result] assert len(ids) == len(set(ids)), "All IDs should be unique" def test_invalid_items_filtered_out(self): mock_llm = Mock() items = [ {"headline": "Valid", "summary": "Yes"}, {"headline": "", "summary": "No headline"}, {"summary": "Missing headline entirely"}, {"headline": "Also valid", "summary": "Content"}, ] mock_llm.invoke.return_value = Mock(content=json.dumps(items)) na = NewsAnalyzer(llm_client=mock_llm) result = na.extract_news_items([{"title": "source"}]) assert len(result) == 2 headlines = {r["headline"] for r in result} assert "Valid" in headlines assert "Also valid" in headlines def test_max_items_caps_output(self): mock_llm = Mock() items = [{"headline": f"H{i}", "summary": f"S{i}"} for i in range(20)] mock_llm.invoke.return_value = Mock(content=json.dumps(items)) na = NewsAnalyzer(llm_client=mock_llm) result = na.extract_news_items([{"title": "source"}], max_items=3) assert len(result) <= 3 # =========================================================================== # _validate_news_item + _count_categories + _summarize_impact combined # =========================================================================== class TestHelperInteraction: """Test helpers work together on realistic data.""" def test_realistic_news_items(self, analyzer): items = [ { "headline": "AI Model Surpasses Human Benchmark", "summary": "A new AI model has exceeded human performance on key tasks.", "category": "Technology", "impact_score": 9, }, { "headline": "Markets Rally on Rate Cut Hopes", "summary": "Global stock markets surged amid expectations of rate cuts.", "category": "Finance", "impact_score": 7, }, { "headline": "Climate Summit Reaches Agreement", "summary": "World leaders agreed on new carbon reduction targets.", "category": "Environment", "impact_score": 8, }, { "headline": "Tech Giant Faces Antitrust Probe", "summary": "Regulators launched a new investigation into market dominance.", "category": "Technology", "impact_score": 6, }, ] # All items should be valid for item in items: assert analyzer._validate_news_item(item) # Category counts cats = analyzer._count_categories(items) assert cats == {"Technology": 2, "Finance": 1, "Environment": 1} # Impact summary impact = analyzer._summarize_impact(items) assert impact["average"] == 7.5 assert impact["high_impact_count"] == 2 # scores 9 and 8 assert impact["max"] == 9 assert impact["min"] == 6 def test_all_invalid_items(self, analyzer): items = [ {"headline": "", "summary": ""}, {"category": "Tech"}, {}, ] for item in items: assert not analyzer._validate_news_item(item) def test_count_categories_with_all_defaults(self, analyzer): """Items with no category key all map to 'Other'.""" items = [{"headline": "A"}, {"headline": "B"}, {"headline": "C"}] result = analyzer._count_categories(items) assert result == {"Other": 3} def test_summarize_impact_all_defaults(self, analyzer): """Items with no impact_score all default to 5.""" items = [{"headline": "A"}, {"headline": "B"}] result = analyzer._summarize_impact(items) assert result["average"] == 5.0 assert result["max"] == 5 assert result["min"] == 5 assert result["high_impact_count"] == 0