Files
sentiment-engine/sentiment_engine/tests/unit/test_schemas_comprehensive.py

533 lines
18 KiB
Python

"""
Comprehensive tests for Pydantic schemas (v2).
"""
import pytest
from datetime import datetime
from pydantic import ValidationError
from sentiment_engine.schemas.payload import (
NormalizedPayload, SourceType, AssetMention, EngagementMetrics
)
from sentiment_engine.schemas.processed import (
ProcessedItem, EntityExtraction, SentimentScores, EmotionScores,
EventClassification, EventType, TemporalAnchor, CredibilityScore
)
from sentiment_engine.schemas.output import (
AssetSentiment, MarketSentiment, IndustrySentiment, SentimentOutput, PumpDumpScore, VelocityMetrics, EventFlag,
)
from sentiment_engine.schemas.config import (
RSSConnectorConfig, APIConnectorConfig, TwitterConnectorConfig,
RedditConnectorConfig, DiscordConnectorConfig, TelegramConnectorConfig,
WebCrawlConnectorConfig, ConnectorConfig
)
class TestSourceType:
"""Tests for SourceType enum"""
def test_all_values(self):
"""All expected values should exist"""
expected = {"news", "social", "exchange_ann", "regulatory", "corporate", "forum", "on_chain"}
actual = {s.value for s in SourceType}
assert actual == expected
def test_string_conversion(self):
"""Should convert to string correctly"""
assert str(SourceType.NEWS) == "news"
assert str(SourceType.SOCIAL) == "social"
class TestAssetMention:
"""Tests for AssetMention schema"""
def test_valid_creation(self):
"""Should create valid AssetMention"""
mention = AssetMention(
asset_id="BTC",
mention_span=(0, 3),
confidence=0.9,
source_text="BTC",
mention_type="ticker"
)
assert mention.asset_id == "BTC"
assert mention.confidence == 0.9
def test_confidence_bounds(self):
"""Confidence should be in [0, 1]"""
# Valid
mention = AssetMention(
asset_id="BTC", mention_span=(0, 3), confidence=0.5,
source_text="BTC", mention_type="ticker"
)
assert mention.confidence == 0.5
# Invalid - too high
with pytest.raises(ValidationError):
AssetMention(
asset_id="BTC", mention_span=(0, 3), confidence=1.5,
source_text="BTC", mention_type="ticker"
)
# Invalid - too low
with pytest.raises(ValidationError):
AssetMention(
asset_id="BTC", mention_span=(0, 3), confidence=-0.1,
source_text="BTC", mention_type="ticker"
)
def test_mention_span_tuple(self):
"""Mention span should be tuple of two ints"""
mention = AssetMention(
asset_id="BTC", mention_span=(10, 13), confidence=0.9,
source_text="BTC", mention_type="ticker"
)
assert mention.mention_span == (10, 13)
assert len(mention.mention_span) == 2
class TestEngagementMetrics:
"""Tests for EngagementMetrics schema"""
def test_defaults(self):
"""All fields should default to 0"""
metrics = EngagementMetrics()
assert metrics.retweets == 0
assert metrics.likes == 0
assert metrics.replies == 0
assert metrics.upvotes == 0
assert metrics.comments == 0
assert metrics.views == 0
assert metrics.shares == 0
def test_total_engagement(self):
"""total_engagement should sum all fields"""
metrics = EngagementMetrics(
retweets=10, likes=100, replies=5,
upvotes=20, comments=15, views=1000, shares=3
)
assert metrics.total_engagement() == 1148
class TestNormalizedPayload:
"""Tests for NormalizedPayload schema"""
def test_valid_creation(self):
"""Should create valid payload"""
payload = NormalizedPayload(
source_id="test",
source_type=SourceType.NEWS,
source_credibility_base=0.8,
ingest_ts=1700000000.0,
publish_ts=1700000000.0,
content_length=100,
raw_text="Test content",
metadata={}
)
assert payload.source_id == "test"
assert payload.source_credibility_base == 0.8
def test_credibility_bounds(self):
"""Credibility should be in [0, 1]"""
with pytest.raises(ValidationError):
NormalizedPayload(
source_id="test", source_type=SourceType.NEWS,
source_credibility_base=1.5,
ingest_ts=1700000000.0, content_length=10,
raw_text="test", metadata={}
)
def test_raw_text_validation(self):
"""raw_text should not be empty"""
with pytest.raises(ValidationError):
NormalizedPayload(
source_id="test", source_type=SourceType.NEWS,
source_credibility_base=0.8,
ingest_ts=1700000000.0, content_length=0,
raw_text="", metadata={}
)
def test_content_length_matches(self):
"""content_length should match raw_text"""
# This is a logical constraint, not enforced by schema
payload = NormalizedPayload(
source_id="test", source_type=SourceType.NEWS,
source_credibility_base=0.8,
ingest_ts=1700000000.0, content_length=100,
raw_text="short", metadata={}
)
assert payload.content_length != len(payload.raw_text)
def test_age_minutes_property(self):
"""age_minutes should calculate correctly"""
ingest_ts = 1700000000.0
publish_ts = 1700000000.0 - 3600 # 1 hour before
payload = NormalizedPayload(
source_id="test", source_type=SourceType.NEWS,
source_credibility_base=0.8,
ingest_ts=ingest_ts, publish_ts=publish_ts,
content_length=10, raw_text="test", metadata={}
)
assert payload.age_minutes == 60.0
def test_has_assets_property(self):
"""has_assets should reflect asset_mentions"""
payload = NormalizedPayload(
source_id="test", source_type=SourceType.NEWS,
source_credibility_base=0.8,
ingest_ts=1700000000.0, content_length=10,
raw_text="test", metadata={},
asset_mentions=[]
)
assert payload.has_assets is False
payload.asset_mentions.append(
AssetMention(asset_id="BTC", mention_span=(0,3), confidence=0.9, source_text="BTC", mention_type="ticker")
)
assert payload.has_assets is True
class TestSentimentScores:
"""Tests for SentimentScores schema"""
def test_valid_creation(self):
"""Should create valid scores"""
scores = SentimentScores(
polarity=0.5, confidence=0.8,
positive_prob=0.7, negative_prob=0.1, neutral_prob=0.2
)
assert scores.polarity == 0.5
assert scores.confidence == 0.8
def test_polarity_bounds(self):
"""Polarity should be in [-1, 1]"""
with pytest.raises(ValidationError):
SentimentScores(
polarity=1.5, confidence=0.5,
positive_prob=0.5, negative_prob=0.2, neutral_prob=0.3
)
def test_confidence_bounds(self):
"""Confidence should be in [0, 1]"""
with pytest.raises(ValidationError):
SentimentScores(
polarity=0.5, confidence=1.5,
positive_prob=0.5, negative_prob=0.2, neutral_prob=0.3
)
def test_probabilities_sum(self):
"""Probabilities should be in [0, 1]"""
scores = SentimentScores(
polarity=0.5, confidence=0.8,
positive_prob=0.7, negative_prob=0.1, neutral_prob=0.2
)
assert 0 <= scores.positive_prob <= 1
assert 0 <= scores.negative_prob <= 1
assert 0 <= scores.neutral_prob <= 1
class TestEmotionScores:
"""Tests for EmotionScores schema"""
def test_valid_creation(self):
"""Should create valid emotion scores"""
scores = EmotionScores(
joy=0.8, fear=0.1, anger=0.0, greed=0.5, sadness=0.0, intensity=0.8
)
assert scores.joy == 0.8
assert scores.intensity == 0.8
def test_emotion_bounds(self):
"""All emotions should be in [0, 1]"""
with pytest.raises(ValidationError):
EmotionScores(
joy=1.5, fear=0.1, anger=0.0, greed=0.5, sadness=0.0, intensity=0.8
)
def test_intensity_bounds(self):
"""Intensity should be in [0, 1]"""
with pytest.raises(ValidationError):
EmotionScores(
joy=0.8, fear=0.1, anger=0.0, greed=0.5, sadness=0.0, intensity=1.5
)
class TestEventClassification:
"""Tests for EventClassification schema"""
def test_valid_creation(self):
"""Should create valid event classification"""
event = EventClassification(
event_type=EventType.LISTING,
confidence=0.8,
assets_involved=["BTC"],
key_details={"matched_keywords": ["listing"]},
severity=0.5
)
assert event.event_type == EventType.LISTING
assert event.confidence == 0.8
def test_confidence_bounds(self):
"""Confidence should be in [0, 1]"""
with pytest.raises(ValidationError):
EventClassification(
event_type=EventType.LISTING,
confidence=1.5,
assets_involved=[],
key_details={},
severity=0.5
)
def test_severity_bounds(self):
"""Severity should be in [0, 1]"""
with pytest.raises(ValidationError):
EventClassification(
event_type=EventType.LISTING,
confidence=0.8,
assets_involved=[],
key_details={},
severity=1.5
)
class TestTemporalAnchor:
"""Tests for TemporalAnchor schema"""
def test_valid_creation(self):
"""Should create valid temporal anchor"""
anchor = TemporalAnchor(
event_time=None,
time_horizon="immediate",
is_breaking=True,
is_scheduled=False,
scheduled_time=None
)
assert anchor.time_horizon == "immediate"
assert anchor.is_breaking is True
def test_time_horizon_values(self):
"""time_horizon should accept valid values"""
for horizon in ["immediate", "near", "medium", "long"]:
anchor = TemporalAnchor(
event_time=None, time_horizon=horizon,
is_breaking=False, is_scheduled=False, scheduled_time=None
)
assert anchor.time_horizon == horizon
def test_event_time_optional(self):
"""event_time should be optional"""
anchor = TemporalAnchor(
event_time=None, time_horizon="immediate",
is_breaking=False, is_scheduled=False, scheduled_time=None
)
assert anchor.event_time is None
def test_scheduled_time_when_scheduled(self):
"""scheduled_time should be present when is_scheduled=True"""
anchor = TemporalAnchor(
event_time=None, time_horizon="near",
is_breaking=False, is_scheduled=True,
scheduled_time=1700000000.0
)
assert anchor.scheduled_time == 1700000000.0
class TestCredibilityScore:
"""Tests for CredibilityScore schema"""
def test_compute_method(self):
"""compute classmethod should create valid score"""
cred = CredibilityScore.compute(
source_base=0.8,
content_quality=0.7,
engagement_authenticity=0.6,
cross_source=0.5,
historical=0.9
)
assert isinstance(cred, CredibilityScore)
assert 0 <= cred.composite <= 1
assert cred.source_base == 0.8
def test_composite_formula(self):
"""Composite should match weighted formula"""
cred = CredibilityScore.compute(
source_base=1.0,
content_quality=1.0,
engagement_authenticity=1.0,
cross_source=1.0,
historical=1.0
)
expected = 0.3 + 0.25 + 0.2 + 0.15 + 0.1
assert cred.composite == min(1.0, expected)
def test_composite_capped_at_one(self):
"""Composite should be capped at 1.0"""
cred = CredibilityScore.compute(
source_base=1.0, content_quality=1.0,
engagement_authenticity=1.0, cross_source=1.0, historical=1.0
)
assert cred.composite <= 1.0
class TestProcessedItem:
"""Tests for ProcessedItem schema"""
def test_valid_creation(self):
"""Should create valid processed item"""
from sentiment_engine.schemas.processed import (
SentimentScores, EmotionScores, EventClassification, EventType,
TemporalAnchor, CredibilityScore
)
item = ProcessedItem(
payload_id="test:123",
source_id="test_source",
source_type="news",
ingest_ts=1700000000.0,
publish_ts=1700000000.0,
entities=[],
sentiment_per_asset={},
emotions_per_asset={},
events=[],
temporal=TemporalAnchor(event_time=None, time_horizon="immediate", is_breaking=False, is_scheduled=False, scheduled_time=None),
credibility=CredibilityScore(source_base=0.5, content_quality=0.5, engagement_authenticity=0.5, cross_source_corroboration=0.0, historical_accuracy=0.5, composite=0.5),
processed_ts=1700000000.0,
processing_latency_ms=100.0,
model_versions={}
)
assert item.payload_id == "test:123"
assert item.processing_latency_ms == 100.0
class TestOutputSchemas:
"""Tests for output schemas"""
def test_asset_sentiment(self):
"""AssetSentiment should validate"""
from sentiment_engine.schemas.output import AssetSentiment
asset = AssetSentiment(
asset_id="BTC",
sentiment=SentimentScores(polarity=0.5, confidence=0.8, positive_prob=0.7, negative_prob=0.1, neutral_prob=0.2),
emotions=EmotionScores(joy=0.5, fear=0.1, anger=0.0, greed=0.3, sadness=0.0, intensity=0.5),
events=[],
mention_count=5
)
assert asset.asset_id == "BTC"
def test_sentiment_output(self):
"""SentimentOutput should validate"""
from sentiment_engine.schemas.output import SentimentOutput, AssetSentiment
output = SentimentOutput(
timestamp=1700000000.0,
assets={"BTC": AssetSentiment(asset_id="BTC", sentiment=SentimentScores(polarity=0.5, confidence=0.8, positive_prob=0.7, negative_prob=0.1, neutral_prob=0.2), emotions=EmotionScores(joy=0.5, fear=0.1, anger=0.0, greed=0.3, sadness=0.0, intensity=0.5), events=[], mention_count=5)},
market_fear_greed=50.0,
global_sentiment=0.5
)
assert output.timestamp == 1700000000.0
class TestConnectorConfigs:
"""Tests for connector configuration schemas"""
def test_base_connector_config(self):
"""Base connector config should validate"""
config = ConnectorConfig(
name="test",
source_type="news",
poll_interval_seconds=300,
timeout_seconds=30
)
assert config.name == "test"
assert config.poll_interval_seconds == 300
def test_rss_connector_config(self):
"""RSS connector config should validate"""
config = RSSConnectorConfig(
name="rss_test",
source_type="news",
feed_urls=["https://example.com/rss"],
max_items_per_feed=50
)
assert config.feed_urls == ["https://example.com/rss"]
def test_api_connector_config(self):
"""API connector config should validate"""
config = APIConnectorConfig(
name="api_test",
source_type="news",
base_url="https://api.example.com",
endpoints=["/v1/news"]
)
assert config.base_url == "https://api.example.com"
def test_twitter_connector_config(self):
"""Twitter connector config should validate"""
config = TwitterConnectorConfig(
name="twitter_test",
source_type="social",
bearer_token="test_token"
)
assert config.bearer_token == "test_token"
def test_reddit_connector_config(self):
"""Reddit connector config should validate"""
config = RedditConnectorConfig(
name="reddit_test",
source_type="social",
client_id="test_id",
client_secret="test_secret",
subreddits=["CryptoCurrency"]
)
assert "CryptoCurrency" in config.subreddits
def test_rate_limits_bounds(self):
"""Rate limits should be positive"""
with pytest.raises(ValidationError):
ConnectorConfig(
name="test", source_type="news",
rate_limit_rps=-1
)
with pytest.raises(ValidationError):
ConnectorConfig(
name="test", source_type="news",
rate_limit_rpm=0
)
def test_backoff_bounds(self):
"""Backoff parameters should be positive"""
with pytest.raises(ValidationError):
ConnectorConfig(
name="test", source_type="news",
backoff_base_seconds=-1
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])