533 lines
18 KiB
Python
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"])
|