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
414 lines
13 KiB
Python
414 lines
13 KiB
Python
"""
|
|
Deep behavioral tests for CardFactory patterns.
|
|
Tests card type registry, card reconstruction, source construction,
|
|
and create_news_card_from_analysis data mapping.
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.news.core.base_card import (
|
|
BaseCard,
|
|
CardSource,
|
|
NewsCard,
|
|
ResearchCard,
|
|
UpdateCard,
|
|
OverviewCard,
|
|
)
|
|
from local_deep_research.news.core.card_factory import CardFactory
|
|
|
|
|
|
# --- Card type registry ---
|
|
|
|
|
|
class TestCardTypeRegistry:
|
|
"""Tests for the card type registry."""
|
|
|
|
def test_has_news_type(self):
|
|
assert "news" in CardFactory._card_types
|
|
|
|
def test_has_research_type(self):
|
|
assert "research" in CardFactory._card_types
|
|
|
|
def test_has_update_type(self):
|
|
assert "update" in CardFactory._card_types
|
|
|
|
def test_has_overview_type(self):
|
|
assert "overview" in CardFactory._card_types
|
|
|
|
def test_news_maps_to_news_card(self):
|
|
assert CardFactory._card_types["news"] is NewsCard
|
|
|
|
def test_research_maps_to_research_card(self):
|
|
assert CardFactory._card_types["research"] is ResearchCard
|
|
|
|
def test_update_maps_to_update_card(self):
|
|
assert CardFactory._card_types["update"] is UpdateCard
|
|
|
|
def test_overview_maps_to_overview_card(self):
|
|
assert CardFactory._card_types["overview"] is OverviewCard
|
|
|
|
def test_register_custom_type(self):
|
|
class CustomCard(BaseCard):
|
|
card_type = "custom"
|
|
|
|
def _extract_headline(self, data):
|
|
return "h"
|
|
|
|
def _extract_summary(self, data):
|
|
return "s"
|
|
|
|
def _calculate_impact(self, data):
|
|
return 5
|
|
|
|
def _extract_topics(self, data):
|
|
return []
|
|
|
|
def _extract_entities(self, data):
|
|
return {}
|
|
|
|
CardFactory.register_card_type("custom", CustomCard)
|
|
assert "custom" in CardFactory._card_types
|
|
# Cleanup
|
|
del CardFactory._card_types["custom"]
|
|
|
|
def test_register_non_basecard_raises(self):
|
|
with pytest.raises(ValueError, match="must be a subclass"):
|
|
CardFactory.register_card_type("bad", dict)
|
|
|
|
|
|
# --- Card reconstruction ---
|
|
|
|
|
|
class TestCardReconstruction:
|
|
"""Tests for _reconstruct_card logic."""
|
|
|
|
def _make_card_data(self, **overrides):
|
|
base = {
|
|
"id": str(uuid.uuid4()),
|
|
"card_type": "news",
|
|
"topic": "Test Topic",
|
|
"user_id": "user1",
|
|
"source": {
|
|
"type": "test",
|
|
"source_id": "src1",
|
|
"created_from": "Test",
|
|
"metadata": {},
|
|
},
|
|
"created_at": "2025-06-15T12:00:00+00:00",
|
|
"updated_at": None,
|
|
"versions": [],
|
|
"metadata": {},
|
|
"interaction": {},
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
def test_reconstructs_news_card(self):
|
|
data = self._make_card_data(card_type="news")
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card is not None
|
|
assert isinstance(card, NewsCard)
|
|
|
|
def test_reconstructs_research_card(self):
|
|
data = self._make_card_data(card_type="research")
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert isinstance(card, ResearchCard)
|
|
|
|
def test_reconstructs_update_card(self):
|
|
data = self._make_card_data(card_type="update")
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert isinstance(card, UpdateCard)
|
|
|
|
def test_reconstructs_overview_card(self):
|
|
data = self._make_card_data(card_type="overview")
|
|
card = CardFactory._reconstruct_card(data)
|
|
# OverviewCard may require specific constructor args
|
|
if card is not None:
|
|
assert isinstance(card, OverviewCard)
|
|
else:
|
|
# If reconstruction fails, verify the type is registered
|
|
assert "overview" in CardFactory._card_types
|
|
|
|
def test_unknown_type_returns_none(self):
|
|
data = self._make_card_data(card_type="nonexistent")
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card is None
|
|
|
|
def test_preserves_card_id(self):
|
|
card_id = str(uuid.uuid4())
|
|
data = self._make_card_data(id=card_id)
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.id == card_id
|
|
|
|
def test_preserves_topic(self):
|
|
data = self._make_card_data(topic="My Topic")
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.topic == "My Topic"
|
|
|
|
def test_preserves_user_id(self):
|
|
data = self._make_card_data(user_id="user42")
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.user_id == "user42"
|
|
|
|
def test_restores_source(self):
|
|
data = self._make_card_data()
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.source.type == "test"
|
|
assert card.source.source_id == "src1"
|
|
|
|
def test_restores_created_at_string(self):
|
|
data = self._make_card_data(created_at="2025-06-15T12:00:00+00:00")
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.created_at.year == 2025
|
|
assert card.created_at.month == 6
|
|
|
|
def test_restores_created_at_datetime(self):
|
|
dt = datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
|
|
data = self._make_card_data(created_at=dt)
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.created_at == dt
|
|
|
|
def test_handles_z_suffix(self):
|
|
data = self._make_card_data(created_at="2025-06-15T12:00:00Z")
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.created_at.year == 2025
|
|
|
|
def test_restores_versions(self):
|
|
data = self._make_card_data(versions=[{"v": 1}, {"v": 2}])
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert len(card.versions) == 2
|
|
|
|
def test_restores_metadata(self):
|
|
data = self._make_card_data(metadata={"key": "val"})
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.metadata["key"] == "val"
|
|
|
|
def test_restores_interaction(self):
|
|
data = self._make_card_data(interaction={"viewed": True})
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.interaction["viewed"] is True
|
|
|
|
def test_none_user_id_becomes_unknown(self):
|
|
data = self._make_card_data(user_id=None)
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.user_id == "unknown"
|
|
|
|
def test_missing_source_constructs_from_flat(self):
|
|
data = self._make_card_data()
|
|
data["source"] = {}
|
|
data["source_type"] = "flat_type"
|
|
data["source_id"] = "flat_id"
|
|
data["created_from"] = "flat origin"
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.source.type == "flat_type"
|
|
assert card.source.source_id == "flat_id"
|
|
|
|
def test_enum_card_type_handled(self):
|
|
"""card_type could be an enum with .value attribute."""
|
|
|
|
class FakeEnum:
|
|
value = "news"
|
|
|
|
data = self._make_card_data(card_type=FakeEnum())
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert isinstance(card, NewsCard)
|
|
|
|
def test_uses_title_fallback_for_topic(self):
|
|
data = self._make_card_data()
|
|
del data["topic"]
|
|
data["title"] = "Title Fallback"
|
|
card = CardFactory._reconstruct_card(data)
|
|
assert card.topic == "Title Fallback"
|
|
|
|
def test_exception_returns_none(self):
|
|
# Missing required 'id' key
|
|
card = CardFactory._reconstruct_card({})
|
|
assert card is None
|
|
|
|
|
|
# --- Source construction patterns ---
|
|
|
|
|
|
class TestSourceConstructionPatterns:
|
|
"""Tests for CardSource construction patterns used in factory."""
|
|
|
|
def test_news_search_source(self):
|
|
source = CardSource(
|
|
type="news_search",
|
|
source_id="search-123",
|
|
created_from="News analysis",
|
|
metadata={"analyzer_version": "1.0"},
|
|
)
|
|
assert source.type == "news_search"
|
|
assert source.source_id == "search-123"
|
|
|
|
def test_user_search_source(self):
|
|
source = CardSource(
|
|
type="user_search",
|
|
created_from="Search subscription: AI news",
|
|
)
|
|
assert source.type == "user_search"
|
|
assert "AI news" in source.created_from
|
|
|
|
def test_recommendation_source(self):
|
|
source = CardSource(
|
|
type="recommendation",
|
|
created_from="Recommended based on: user history",
|
|
)
|
|
assert source.type == "recommendation"
|
|
|
|
def test_source_with_empty_metadata(self):
|
|
source = CardSource(type="test", metadata={})
|
|
assert source.metadata == {}
|
|
|
|
def test_source_default_metadata(self):
|
|
source = CardSource(type="test")
|
|
assert source.metadata is not None
|
|
|
|
|
|
# --- create_news_card_from_analysis data mapping ---
|
|
|
|
|
|
class TestNewsCardFromAnalysisMapping:
|
|
"""Tests for the data mapping in create_news_card_from_analysis.
|
|
Tests the data transformation logic without requiring storage.
|
|
"""
|
|
|
|
def test_headline_maps_to_topic(self):
|
|
"""The headline field should become the card topic."""
|
|
news_item = {"headline": "Breaking: AI Advances"}
|
|
# Test the mapping pattern
|
|
topic = news_item.get("headline", "Untitled")
|
|
assert topic == "Breaking: AI Advances"
|
|
|
|
def test_missing_headline_uses_untitled(self):
|
|
news_item = {}
|
|
topic = news_item.get("headline", "Untitled")
|
|
assert topic == "Untitled"
|
|
|
|
def test_category_default_other(self):
|
|
news_item = {}
|
|
category = news_item.get("category", "Other")
|
|
assert category == "Other"
|
|
|
|
def test_impact_score_default_5(self):
|
|
news_item = {}
|
|
impact = news_item.get("impact_score", 5)
|
|
assert impact == 5
|
|
|
|
def test_is_developing_default_false(self):
|
|
news_item = {}
|
|
developing = news_item.get("is_developing", False)
|
|
assert developing is False
|
|
|
|
def test_entities_default_empty(self):
|
|
news_item = {}
|
|
entities = news_item.get("entities", {})
|
|
assert entities == {}
|
|
|
|
def test_topics_default_empty(self):
|
|
news_item = {}
|
|
topics = news_item.get("topics", [])
|
|
assert topics == []
|
|
|
|
def test_source_url_default_empty(self):
|
|
news_item = {}
|
|
url = news_item.get("source_url", "")
|
|
assert url == ""
|
|
|
|
def test_additional_metadata_merged(self):
|
|
"""Additional metadata should be merged into the item's metadata."""
|
|
metadata = {"existing": "val"}
|
|
additional = {"recommender": "TopicBased"}
|
|
metadata.update(additional)
|
|
assert metadata["existing"] == "val"
|
|
assert metadata["recommender"] == "TopicBased"
|
|
|
|
def test_additional_metadata_none_handled(self):
|
|
metadata = {"existing": "val"}
|
|
additional = None
|
|
if additional:
|
|
metadata.update(additional)
|
|
assert metadata == {"existing": "val"}
|
|
|
|
def test_news_source_construction(self):
|
|
"""Source should be news_search type with search ID."""
|
|
source = CardSource(
|
|
type="news_search",
|
|
source_id="search-abc",
|
|
created_from="News analysis",
|
|
metadata={"analyzer_version": "1.0", "extraction_method": "llm"},
|
|
)
|
|
assert source.type == "news_search"
|
|
assert source.metadata["extraction_method"] == "llm"
|
|
|
|
def test_full_news_item_mapping(self):
|
|
"""Test all fields from a realistic news item."""
|
|
news_item = {
|
|
"headline": "AI Breakthrough in Drug Discovery",
|
|
"category": "Technology",
|
|
"summary": "Researchers achieve...",
|
|
"analysis": "This development signifies...",
|
|
"impact_score": 8,
|
|
"entities": {"org": ["DeepMind"]},
|
|
"topics": ["AI", "Healthcare"],
|
|
"source_url": "https://example.com/article",
|
|
"is_developing": True,
|
|
"surprising_element": "10x faster than expected",
|
|
"metadata": {"source_domain": "example.com"},
|
|
}
|
|
assert news_item["headline"] == "AI Breakthrough in Drug Discovery"
|
|
assert news_item["impact_score"] == 8
|
|
assert news_item["is_developing"] is True
|
|
assert len(news_item["topics"]) == 2
|
|
|
|
|
|
# --- get_storage patterns ---
|
|
|
|
|
|
class TestGetStoragePatterns:
|
|
"""Tests for get_storage error behavior."""
|
|
|
|
def test_raises_without_session_or_flask(self):
|
|
# Reset singleton
|
|
CardFactory._storage = None
|
|
with pytest.raises(RuntimeError, match="No database session"):
|
|
CardFactory.get_storage()
|
|
|
|
def test_provided_session_used(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
session = MagicMock()
|
|
storage = CardFactory.get_storage(session)
|
|
assert storage is not None
|
|
|
|
|
|
# --- create_card validation ---
|
|
|
|
|
|
class TestCreateCardValidation:
|
|
"""Tests for create_card type validation."""
|
|
|
|
def test_unknown_type_raises(self):
|
|
source = CardSource(type="test")
|
|
with pytest.raises(ValueError, match="Unknown card type"):
|
|
CardFactory.create_card(
|
|
card_type="nonexistent",
|
|
topic="Test",
|
|
source=source,
|
|
user_id="u1",
|
|
)
|
|
|
|
def test_error_lists_available_types(self):
|
|
source = CardSource(type="test")
|
|
with pytest.raises(ValueError, match="Available types"):
|
|
CardFactory.create_card(
|
|
card_type="nonexistent",
|
|
topic="Test",
|
|
source=source,
|
|
user_id="u1",
|
|
)
|