""" Deep behavioral tests for BaseCard and all card subclasses. Tests real logic: initialization, helper methods, serialization, and edge cases. """ import uuid from datetime import datetime, timezone from unittest.mock import Mock import pytest from local_deep_research.news.core.base_card import ( CardSource, CardVersion, NewsCard, OverviewCard, ResearchCard, UpdateCard, ) # --- CardSource tests --- class TestCardSourceDataclass: """Tests for CardSource dataclass behavior.""" def test_requires_type(self): """Test type is required.""" with pytest.raises(TypeError): CardSource() def test_type_stored(self): source = CardSource(type="news_item") assert source.type == "news_item" def test_source_id_defaults_none(self): source = CardSource(type="news_item") assert source.source_id is None def test_created_from_defaults_empty(self): source = CardSource(type="news_item") assert source.created_from == "" def test_metadata_defaults_empty_dict(self): source = CardSource(type="news_item") assert source.metadata == {} def test_metadata_independent_per_instance(self): """Test metadata dicts are independent between instances.""" s1 = CardSource(type="a") s2 = CardSource(type="b") s1.metadata["key"] = "value" assert "key" not in s2.metadata def test_all_fields(self): source = CardSource( type="user_search", source_id="search-123", created_from="manual", metadata={"custom": True}, ) assert source.type == "user_search" assert source.source_id == "search-123" assert source.created_from == "manual" assert source.metadata == {"custom": True} # --- CardVersion tests --- class TestCardVersionDataclass: """Tests for CardVersion dataclass behavior.""" def test_auto_generates_version_id_if_empty(self): """Test __post_init__ generates id when version_id is empty string.""" v = CardVersion( version_id="", created_at=datetime.now(timezone.utc), content={"data": 1}, query_used="test", ) assert v.version_id != "" uuid.UUID(v.version_id) # Should be valid UUID def test_keeps_provided_version_id(self): v = CardVersion( version_id="custom-id", created_at=datetime.now(timezone.utc), content={}, query_used="q", ) assert v.version_id == "custom-id" def test_stores_content(self): content = {"findings": ["a", "b"]} v = CardVersion( version_id="v1", created_at=datetime.now(timezone.utc), content=content, query_used="q", ) assert v.content == content def test_stores_query_used(self): v = CardVersion( version_id="v1", created_at=datetime.now(timezone.utc), content={}, query_used="artificial intelligence", ) assert v.query_used == "artificial intelligence" def test_search_strategy_defaults_none(self): v = CardVersion( version_id="v1", created_at=datetime.now(timezone.utc), content={}, query_used="q", ) assert v.search_strategy is None def test_search_strategy_stored(self): v = CardVersion( version_id="v1", created_at=datetime.now(timezone.utc), content={}, query_used="q", search_strategy="news_aggregation", ) assert v.search_strategy == "news_aggregation" # --- NewsCard tests --- class TestNewsCardInit: """Tests for NewsCard initialization and __post_init__.""" def _make_source(self): return CardSource(type="news_item", source_id="src-1") def test_id_is_valid_uuid(self): card = NewsCard(topic="AI", source=self._make_source(), user_id="u1") uuid.UUID(card.id) def test_custom_card_id_used(self): card = NewsCard( topic="AI", source=self._make_source(), user_id="u1", card_id="custom-id", ) assert card.id == "custom-id" def test_created_at_is_utc(self): card = NewsCard(topic="AI", source=self._make_source(), user_id="u1") assert card.created_at.tzinfo is not None def test_updated_at_set(self): card = NewsCard(topic="AI", source=self._make_source(), user_id="u1") assert card.updated_at is not None def test_interaction_initialized(self): card = NewsCard(topic="AI", source=self._make_source(), user_id="u1") assert card.interaction == { "votes_up": 0, "votes_down": 0, "views": 0, "shares": 0, } def test_headline_defaults_to_topic(self): card = NewsCard( topic="Big News", source=self._make_source(), user_id="u1" ) assert card.headline == "Big News" def test_headline_override(self): card = NewsCard( topic="Topic", source=self._make_source(), user_id="u1", headline="Custom Headline", ) assert card.headline == "Custom Headline" def test_default_category(self): card = NewsCard(topic="AI", source=self._make_source(), user_id="u1") assert card.category == "General" def test_default_impact_score(self): card = NewsCard(topic="AI", source=self._make_source(), user_id="u1") assert card.impact_score == 5 def test_default_entities(self): card = NewsCard(topic="AI", source=self._make_source(), user_id="u1") assert card.entities == { "people": [], "places": [], "organizations": [], } def test_default_is_developing(self): card = NewsCard(topic="AI", source=self._make_source(), user_id="u1") assert card.is_developing is False def test_versions_empty_by_default(self): card = NewsCard(topic="AI", source=self._make_source(), user_id="u1") assert card.versions == [] def test_get_card_type(self): card = NewsCard(topic="AI", source=self._make_source(), user_id="u1") assert card.get_card_type() == "news" class TestNewsCardToDict: """Tests for NewsCard.to_dict().""" def _make_card(self, **kwargs): source = CardSource(type="news_item", source_id="src-1") defaults = { "topic": "Test", "source": source, "user_id": "u1", } defaults.update(kwargs) return NewsCard(**defaults) def test_includes_id(self): card = self._make_card() d = card.to_dict() assert "id" in d def test_includes_topic(self): card = self._make_card(topic="Science") d = card.to_dict() assert d["topic"] == "Science" def test_includes_card_type(self): card = self._make_card() d = card.to_dict() assert d["card_type"] == "news" def test_includes_headline(self): card = self._make_card(headline="Breaking!") d = card.to_dict() assert d["headline"] == "Breaking!" def test_includes_category(self): card = self._make_card(category="Tech") d = card.to_dict() assert d["category"] == "Tech" def test_includes_impact_score(self): card = self._make_card(impact_score=8) d = card.to_dict() assert d["impact_score"] == 8 def test_includes_is_developing(self): card = self._make_card(is_developing=True) d = card.to_dict() assert d["is_developing"] is True def test_includes_source_info(self): card = self._make_card() d = card.to_dict() assert d["source"]["type"] == "news_item" assert d["source"]["source_id"] == "src-1" def test_versions_count(self): card = self._make_card() d = card.to_dict() assert d["versions_count"] == 0 def test_includes_user_id(self): card = self._make_card(user_id="user42") d = card.to_dict() assert d["user_id"] == "user42" def test_includes_interaction(self): card = self._make_card() d = card.to_dict() assert "interaction" in d assert d["interaction"]["votes_up"] == 0 def test_created_at_is_isoformat(self): card = self._make_card() d = card.to_dict() # Should be parseable ISO format datetime.fromisoformat(d["created_at"]) # --- BaseCard helper methods --- class TestExtractHeadline: """Tests for BaseCard._extract_headline().""" def _make_card(self): source = CardSource(type="news_item") return NewsCard(topic="Test", source=source, user_id="u1") def test_uses_headline_field(self): card = self._make_card() result = card._extract_headline({"headline": "Big Story"}) assert result == "Big Story" def test_falls_back_to_title(self): card = self._make_card() result = card._extract_headline({"title": "A Title"}) assert result == "A Title" def test_falls_back_to_query_truncated(self): card = self._make_card() long_query = "x" * 200 result = card._extract_headline({"query": long_query}) assert len(result) <= 100 def test_returns_empty_if_nothing(self): card = self._make_card() result = card._extract_headline({}) assert result == "" def test_headline_takes_priority_over_title(self): card = self._make_card() result = card._extract_headline({"headline": "H", "title": "T"}) assert result == "H" class TestExtractSummary: """Tests for BaseCard._extract_summary().""" def _make_card(self): source = CardSource(type="news_item") return NewsCard(topic="Test", source=source, user_id="u1") def test_uses_summary_field(self): card = self._make_card() result = card._extract_summary({"summary": "A summary"}) assert result == "A summary" def test_falls_back_to_current_knowledge(self): card = self._make_card() result = card._extract_summary({"current_knowledge": "Knowledge"}) assert result == "Knowledge" def test_falls_back_to_formatted_findings_truncated(self): card = self._make_card() long_findings = "y" * 1000 result = card._extract_summary({"formatted_findings": long_findings}) assert len(result) <= 500 def test_returns_empty_if_nothing(self): card = self._make_card() result = card._extract_summary({}) assert result == "" class TestCalculateImpact: """Tests for BaseCard._calculate_impact() scoring logic.""" def _make_card(self): source = CardSource(type="news_item") return NewsCard(topic="Test", source=source, user_id="u1") def test_base_score_is_5_with_no_findings(self): card = self._make_card() score = card._calculate_impact({}) assert score == 5 def test_score_increases_with_findings(self): card = self._make_card() findings = [f"finding-{i}" for i in range(10)] score = card._calculate_impact({"findings": findings}) assert score > 5 def test_score_increases_with_sources(self): card = self._make_card() sources = [f"source-{i}" for i in range(9)] score = card._calculate_impact({"sources": sources}) assert score > 5 def test_score_capped_at_10(self): card = self._make_card() result = {"findings": list(range(100)), "sources": list(range(100))} score = card._calculate_impact(result) assert score == 10 def test_score_minimum_is_1(self): card = self._make_card() score = card._calculate_impact({"findings": [], "sources": []}) assert score >= 1 def test_score_formula_5_findings_adds_1(self): card = self._make_card() # 5 findings = 5 + 1 = 6 score = card._calculate_impact({"findings": [1, 2, 3, 4, 5]}) assert score == 6 def test_score_formula_3_sources_adds_1(self): card = self._make_card() # 3 sources = 5 + 1 = 6 score = card._calculate_impact({"sources": [1, 2, 3]}) assert score == 6 def test_combined_scoring(self): card = self._make_card() # 10 findings + 6 sources = 5 + 2 + 2 = 9 score = card._calculate_impact( {"findings": list(range(10)), "sources": list(range(6))} ) assert score == 9 class TestExtractTopics: """Tests for BaseCard._extract_topics().""" def _make_card(self): source = CardSource(type="news_item") return NewsCard(topic="Test", source=source, user_id="u1") def test_uses_topics_field(self): card = self._make_card() result = card._extract_topics({"topics": ["AI", "ML"]}) assert result == ["AI", "ML"] def test_extracts_from_query_when_no_topics(self): card = self._make_card() result = card._extract_topics( {"query": "artificial intelligence breakthroughs"} ) # Only words > 4 chars assert "artificial" in result assert "intelligence" in result assert "breakthroughs" in result def test_limits_extracted_topics_to_5(self): card = self._make_card() long_query = " ".join([f"longword{i}" for i in range(20)]) result = card._extract_topics({"query": long_query}) assert len(result) <= 5 def test_filters_short_words_from_query(self): card = self._make_card() result = card._extract_topics({"query": "the big new ai event today"}) assert "the" not in result assert "big" not in result assert "new" not in result assert "ai" not in result def test_returns_empty_when_no_data(self): card = self._make_card() result = card._extract_topics({}) assert result == [] class TestExtractEntities: """Tests for BaseCard._extract_entities().""" def _make_card(self): source = CardSource(type="news_item") return NewsCard(topic="Test", source=source, user_id="u1") def test_uses_entities_field(self): card = self._make_card() entities = {"people": ["Alice"], "places": [], "organizations": []} result = card._extract_entities({"entities": entities}) assert result == entities def test_default_empty_entities(self): card = self._make_card() result = card._extract_entities({}) assert result == {"people": [], "places": [], "organizations": []} class TestGetLatestVersion: """Tests for BaseCard.get_latest_version().""" def _make_card(self): source = CardSource(type="news_item") return NewsCard(topic="Test", source=source, user_id="u1") def test_returns_none_when_no_versions(self): card = self._make_card() assert card.get_latest_version() is None def test_returns_single_version(self): card = self._make_card() v = CardVersion( version_id="v1", created_at=datetime.now(timezone.utc), content={}, query_used="q", ) card.versions.append(v) assert card.get_latest_version() is v def test_returns_latest_by_created_at(self): card = self._make_card() v1 = CardVersion( version_id="v1", created_at=datetime(2024, 1, 1, tzinfo=timezone.utc), content={}, query_used="q", ) v2 = CardVersion( version_id="v2", created_at=datetime(2024, 6, 1, tzinfo=timezone.utc), content={}, query_used="q", ) v3 = CardVersion( version_id="v3", created_at=datetime(2024, 3, 1, tzinfo=timezone.utc), content={}, query_used="q", ) card.versions.extend([v1, v2, v3]) assert card.get_latest_version() is v2 class TestProgressCallback: """Tests for progress callback mechanism.""" def _make_card(self): source = CardSource(type="news_item") return NewsCard(topic="Test", source=source, user_id="u1") def test_callback_not_called_when_not_set(self): card = self._make_card() # Should not raise card._update_progress("msg") def test_callback_called_when_set(self): card = self._make_card() cb = Mock() card.set_progress_callback(cb) card._update_progress("searching", 50, {"step": 1}) cb.assert_called_once_with("searching", 50, {"step": 1}) def test_callback_default_metadata(self): card = self._make_card() cb = Mock() card.set_progress_callback(cb) card._update_progress("msg", 10) cb.assert_called_once_with("msg", 10, {}) # --- ResearchCard tests --- class TestResearchCard: """Tests for ResearchCard-specific behavior.""" def _make_source(self): return CardSource(type="research") def test_get_card_type(self): card = ResearchCard( topic="AI", source=self._make_source(), user_id="u1" ) assert card.get_card_type() == "research" def test_default_depth(self): card = ResearchCard( topic="AI", source=self._make_source(), user_id="u1" ) assert card.research_depth == "quick" def test_default_findings(self): card = ResearchCard( topic="AI", source=self._make_source(), user_id="u1" ) assert card.key_findings == [] def test_default_sources_count(self): card = ResearchCard( topic="AI", source=self._make_source(), user_id="u1" ) assert card.sources_count == 0 def test_to_dict_includes_depth(self): card = ResearchCard( topic="AI", source=self._make_source(), user_id="u1", research_depth="detailed", ) d = card.to_dict() assert d["research_depth"] == "detailed" def test_to_dict_includes_findings(self): card = ResearchCard( topic="AI", source=self._make_source(), user_id="u1", key_findings=["finding1", "finding2"], ) d = card.to_dict() assert d["key_findings"] == ["finding1", "finding2"] def test_to_dict_includes_sources_count(self): card = ResearchCard( topic="AI", source=self._make_source(), user_id="u1", sources_count=15, ) d = card.to_dict() assert d["sources_count"] == 15 # --- UpdateCard tests --- class TestUpdateCard: """Tests for UpdateCard-specific behavior.""" def _make_source(self): return CardSource(type="update") def test_get_card_type(self): card = UpdateCard( topic="Updates", source=self._make_source(), user_id="u1" ) assert card.get_card_type() == "update" def test_default_update_type(self): card = UpdateCard( topic="Updates", source=self._make_source(), user_id="u1" ) assert card.update_type == "new_stories" def test_since_is_set(self): card = UpdateCard( topic="Updates", source=self._make_source(), user_id="u1" ) assert card.since is not None assert card.since.tzinfo is not None def test_to_dict_includes_update_type(self): card = UpdateCard( topic="Updates", source=self._make_source(), user_id="u1", update_type="breaking", ) d = card.to_dict() assert d["update_type"] == "breaking" def test_to_dict_includes_count(self): card = UpdateCard( topic="Updates", source=self._make_source(), user_id="u1", count=42, ) d = card.to_dict() assert d["count"] == 42 def test_to_dict_since_is_isoformat(self): card = UpdateCard( topic="Updates", source=self._make_source(), user_id="u1" ) d = card.to_dict() datetime.fromisoformat(d["since"]) # --- OverviewCard tests --- class TestOverviewCard: """Tests for OverviewCard-specific behavior.""" def _make_source(self): return CardSource(type="overview") def test_get_card_type(self): card = OverviewCard(source=self._make_source(), user_id="u1") assert card.get_card_type() == "overview" def test_topic_defaults_to_news_overview(self): card = OverviewCard(source=self._make_source(), user_id="u1") assert card.topic == "News Overview" def test_default_stats(self): card = OverviewCard(source=self._make_source(), user_id="u1") assert card.stats == { "total_new": 0, "breaking": 0, "relevant": 0, "categories": {}, } def test_default_summary(self): card = OverviewCard(source=self._make_source(), user_id="u1") assert card.summary == "" def test_to_dict_includes_stats(self): card = OverviewCard( source=self._make_source(), user_id="u1", stats={ "total_new": 5, "breaking": 1, "relevant": 3, "categories": {}, }, ) d = card.to_dict() assert d["stats"]["total_new"] == 5 def test_to_dict_includes_trend_analysis(self): card = OverviewCard( source=self._make_source(), user_id="u1", trend_analysis="AI is trending", ) d = card.to_dict() assert d["trend_analysis"] == "AI is trending"