learningcircuit--local-deep-research
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
564 行
21 KiB
Python
564 行
21 KiB
Python
"""
|
|
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
|