""" Property-based tests using Hypothesis for comprehensive edge case coverage. These tests generate thousands of test cases automatically. """ import pytest from hypothesis import given, strategies as st, settings, assume, example import re from sentiment_engine.utils.text import ( clean_html, extract_tickers, extract_cashtags, detect_language, normalize_whitespace, truncate_text ) from sentiment_engine.nlp.entity_extraction import EntityExtractor, AssetMapper from sentiment_engine.nlp.temporal import TemporalAnchorer from sentiment_engine.nlp.credibility import CredibilityScorer from sentiment_engine.schemas.payload import NormalizedPayload, SourceType, AssetMention, EngagementMetrics from sentiment_engine.schemas.processed import ProcessedItem, EntityExtraction, SentimentScores, EmotionScores # ============================================================ # TEXT UTILS PROPERTY TESTS # ============================================================ class TestTextUtilsProperties: """Property-based tests for text utilities""" @given(st.text(min_size=0, max_size=1000)) @settings(max_examples=500) def test_clean_html_idempotent(self, text): """clean_html should be idempotent""" cleaned = clean_html(text) assert clean_html(cleaned) == cleaned @given(st.text(min_size=0, max_size=1000)) @settings(max_examples=500) def test_clean_html_removes_tags(self, text): """clean_html should remove all HTML tags""" html = f"

{text}

" cleaned = clean_html(html) assert "<" not in cleaned or ">" not in cleaned or "<" in cleaned @given(st.text(alphabet=st.characters(blacklist_categories=('Cc', 'Cs')), min_size=0, max_size=500)) @settings(max_examples=500) def test_normalize_whitespace_collapse(self, text): """normalize_whitespace should collapse multiple spaces""" normalized = normalize_whitespace(text) assert " " not in normalized assert normalized == normalized.strip() @given(st.text(min_size=0, max_size=200)) @settings(max_examples=200) def test_truncate_text_length(self, text): """truncate_text should not exceed max_length""" max_len = 50 truncated = truncate_text(text, max_len) assert len(truncated) <= max_len + 3 # +3 for "..." @given(st.lists(st.text(min_size=1, max_size=10), min_size=0, max_size=20)) @settings(max_examples=200) def test_extract_tickers_preserves_case(self, words): """extract_tickers should preserve ticker case""" text = " ".join(words) # Add some explicit tickers text = f"BTC ETH {text} SOL" tickers = extract_tickers(text) assert "BTC" in tickers assert "ETH" in tickers assert "SOL" in tickers @given(st.text(alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$", min_size=2, max_size=10)) @settings(max_examples=200) def test_extract_cashtags_format(self, cashtag): """extract_cashtags should find $TICKER format""" if cashtag.startswith("$") and len(cashtag) >= 3: text = f"Check {cashtag} now" cashtags = extract_cashtags(text) assert cashtag in cashtags # ============================================================ # ENTITY EXTRACTION PROPERTY TESTS # ============================================================ class TestEntityExtractionProperties: """Property-based tests for entity extraction""" @pytest.fixture def extractor(self): return EntityExtractor(AssetMapper()) @given(st.text(min_size=0, max_size=500)) @settings(max_examples=300) async def test_extract_all_returns_list(self, extractor, text): """extract_all should always return a list""" await extractor.initialize() entities = await extractor.extract_all(text) assert isinstance(entities, list) @given(st.text(min_size=0, max_size=500)) @settings(max_examples=300) async def test_extract_all_entities_have_required_fields(self, extractor, text): """All extracted entities should have required fields""" await extractor.initialize() entities = await extractor.extract_all(text) for entity in entities: assert hasattr(entity, 'asset_id') assert hasattr(entity, 'mention_span') assert hasattr(entity, 'confidence') assert hasattr(entity, 'entity_type') assert hasattr(entity, 'canonical_name') assert 0 <= entity.confidence <= 1 @given(st.text(min_size=0, max_size=500)) @settings(max_examples=200) async def test_deduplication_removes_overlaps(self, extractor, text): """Deduplication should remove overlapping mentions""" await extractor.initialize() entities = await extractor.extract_all(text) # Check no overlapping spans spans = [(e.mention_span[0], e.mention_span[1]) for e in entities] for i, (s1, e1) in enumerate(spans): for j, (s2, e2) in enumerate(spans): if i != j: assert not (s1 < e2 and s2 < e1), "Overlapping spans found" @given(st.lists( st.text(alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ", min_size=2, max_size=5), min_size=1, max_size=10 )) @settings(max_examples=200) def test_asset_mapper_known_tickers(self, tickers): """AssetMapper should map known tickers with high confidence""" mapper = AssetMapper() for ticker in set(tickers): asset_id, confidence = mapper.map_ticker(ticker) if ticker in {"BTC", "ETH", "SOL", "AVAX", "MATIC", "DOT", "LINK", "UNI", "AAVE", "ARB", "OP", "SUI"}: assert confidence >= 0.9 # ============================================================ # TEMPORAL ANCHORING PROPERTY TESTS # ============================================================ class TestTemporalAnchoringProperties: """Property-based tests for temporal anchoring""" @pytest.fixture def anchorer(self): return TemporalAnchorer() @given(st.text(min_size=0, max_size=500)) @settings(max_examples=300) def test_anchor_returns_valid_object(self, anchorer, text): """anchor should return a valid TemporalAnchor""" anchor = anchorer.anchor(text) assert hasattr(anchor, 'time_horizon') assert hasattr(anchor, 'is_breaking') assert hasattr(anchor, 'is_scheduled') assert anchor.time_horizon in ["immediate", "near", "medium", "long"] assert isinstance(anchor.is_breaking, bool) assert isinstance(anchor.is_scheduled, bool) @given(st.text(alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 :,-.", min_size=0, max_size=200)) @settings(max_examples=200) def test_breaking_detection_consistency(self, anchorer, text): """Breaking detection should be consistent""" anchor1 = anchorer.anchor(text) anchor2 = anchorer.anchor(text) assert anchor1.is_breaking == anchor2.is_breaking @given(st.text(min_size=0, max_size=200)) @settings(max_examples=200) def test_horizon_ordering(self, anchorer, text): """Horizon should follow expected ordering""" horizon_order = {"immediate": 0, "near": 1, "medium": 2, "long": 3} anchor = anchorer.anchor(text) assert anchor.time_horizon in horizon_order # ============================================================ # CREDIBILITY SCORING PROPERTY TESTS # ============================================================ class TestCredibilityScoringProperties: """Property-based tests for credibility scoring""" @pytest.fixture def scorer(self): return CredibilityScorer() @given(st.text(min_size=10, max_size=1000)) @settings(max_examples=300) def test_composite_score_bounds(self, scorer, text): """Composite score should be in [0, 1]""" scorer.load_registry({"test": {"base_credibility": 0.5}}) cred = scorer.compute_composite( source_id="test", text=text, metadata={}, asset_id="BTC", event_type="listing" ) assert 0 <= cred.composite <= 1 @given( st.floats(min_value=0, max_value=1), st.floats(min_value=0, max_value=1), st.floats(min_value=0, max_value=1), st.floats(min_value=0, max_value=1), st.floats(min_value=0, max_value=1) ) @settings(max_examples=500) def test_composite_formula(self, scorer, source_base, content_quality, engagement_auth, cross_source, historical): """Composite should match weighted formula""" composite = ( 0.3 * source_base + 0.25 * content_quality + 0.2 * engagement_auth + 0.15 * cross_source + 0.1 * historical ) cred = CredibilityScore.compute( source_base=source_base, content_quality=content_quality, engagement_authenticity=engagement_auth, cross_source=cross_source, historical=historical ) assert abs(cred.composite - min(1.0, composite)) < 0.001 @given(st.text(min_size=100, max_size=2000)) @settings(max_examples=200) def test_content_quality_increases_with_length(self, scorer, text): """Content quality should generally increase with length (up to a point)""" short_text = text[:50] long_text = text short_score = scorer.score_content_quality(short_text, {}) long_score = scorer.score_content_quality(long_text, {}) # Longer text should not score significantly lower assert long_score >= short_score - 0.2 # ============================================================ # SCHEMA VALIDATION PROPERTY TESTS # ============================================================ class TestSchemaProperties: """Property-based tests for Pydantic schema validation""" @given( st.text(min_size=1, max_size=100), st.text(min_size=1, max_size=5000), st.floats(min_value=0, max_value=1), st.floats(min_value=1000000000, max_value=2000000000), ) @settings(max_examples=200) def test_normalized_payload_creation(self, source_id, raw_text, credibility, ingest_ts): """NormalizedPayload should accept valid inputs""" payload = NormalizedPayload( source_id=source_id, source_type=SourceType.NEWS, source_credibility_base=credibility, ingest_ts=ingest_ts, publish_ts=ingest_ts, content_length=len(raw_text), raw_text=raw_text, metadata={} ) assert payload.source_id == source_id assert payload.raw_text == raw_text.strip() @given( st.text(min_size=1, max_size=100), st.floats(min_value=-1, max_value=1), st.floats(min_value=0, max_value=1), ) @settings(max_examples=200) def test_sentiment_scores_bounds(self, asset_id, polarity, confidence): """SentimentScores should enforce bounds""" scores = SentimentScores( polarity=max(-1, min(1, polarity)), confidence=max(0, min(1, confidence)), positive_prob=max(0, min(1, (polarity + 1) / 2)), negative_prob=max(0, min(1, (1 - polarity) / 2)), neutral_prob=1 - abs(polarity) ) assert -1 <= scores.polarity <= 1 assert 0 <= scores.confidence <= 1 # ============================================================ # SIGNAL PROCESSING PROPERTY TESTS # ============================================================ class TestSignalProcessingProperties: """Property-based tests for signal processing""" @given(st.floats(min_value=0, max_value=1)) @settings(max_examples=500) def test_fear_greed_bounds(self, value): """Fear/greed index should be in [0, 100]""" from sentiment_engine.signal.processor import FearGreedProcessor # The index is derived from normalized inputs assert 0 <= value <= 1 @given(st.floats(min_value=0, max_value=100)) @settings(max_examples=500) def test_velocity_non_negative(self, value): """Velocity should be non-negative""" assert value >= 0 @given( st.floats(min_value=0, max_value=1), st.floats(min_value=0, max_value=1) ) @settings(max_examples=500) def test_fusion_weighted_average(self, w1, w2): """Fusion should produce weighted average""" # Normalize weights total = w1 + w2 if total > 0: w1, w2 = w1/total, w2/total result = w1 * 0.5 + w2 * 0.8 assert 0 <= result <= 1 # ============================================================ # LABELING PIPELINE PROPERTY TESTS # ============================================================ class TestLabelingPipelineProperties: """Property-based tests for labeling pipeline""" @pytest.fixture def runner(self): from labeling_pipeline import LabelingPipelineRunner return LabelingPipelineRunner() @given(st.text(min_size=20, max_size=500)) @settings(max_examples=100) async def test_labeling_returns_valid_structure(self, runner, text): """Labeling should return valid structure""" from labeling_pipeline import LabelingPipeline pipeline = LabelingPipeline() result = await pipeline.label_text(text) assert "labels" in result assert "confidence" in result assert "verified" in result assert "verification_details" in result assert result["labels"]["sentiment"] in ["Bearish", "Bullish", "Neutral"] assert result["labels"]["event_type"] in [ "listing", "delisting", "hack", "regulatory", "governance", "upgrade", "partnership", "earnings", "macro", "liquidation", "whale", "manipulation" ] @given(st.text(min_size=20, max_size=500)) @settings(max_examples=100) async def test_confidence_bounds(self, runner, text): """Confidence scores should be in [0, 1]""" from labeling_pipeline import LabelingPipeline pipeline = LabelingPipeline() result = await pipeline.label_text(text) assert 0 <= result["confidence"]["sentiment"] <= 1 assert 0 <= result["confidence"]["event"] <= 1 assert 0 <= result["confidence"]["verification"] <= 1 assert 0 <= result["confidence"]["overall"] <= 1 # ============================================================ # ONNX MODEL PROPERTY TESTS # ============================================================ class TestONNXModelProperties: """Property-based tests for ONNX model inference""" @pytest.fixture def finbert_session(self): import onnxruntime as ort return ort.InferenceSession( "models/onnx/finbert/model.onnx", providers=['CPUExecutionProvider'] ) @given(st.integers(min_value=1, max_value=10), st.integers(min_value=16, max_value=256)) @settings(max_examples=100) def test_finbert_input_shapes(self, finbert_session, batch_size, seq_len): """FinBERT should accept various input shapes""" import numpy as np input_ids = np.ones((batch_size, seq_len), dtype=np.int64) attention_mask = np.ones((batch_size, seq_len), dtype=np.int64) token_type_ids = np.zeros((batch_size, seq_len), dtype=np.int64) outputs = finbert_session.run(None, { "input_ids": input_ids, "attention_mask": attention_mask, "token_type_ids": token_type_ids }) assert outputs[0].shape == (batch_size, 3) @given(st.integers(min_value=1, max_value=10), st.integers(min_value=16, max_value=256)) @settings(max_examples=100) def test_bert_events_input_shapes(self, batch_size, seq_len): """BERT Events should accept various input shapes""" import onnxruntime as ort import numpy as np session = ort.InferenceSession( "models/onnx/bert-base-event/model.onnx", providers=['CPUExecutionProvider'] ) input_ids = np.ones((batch_size, seq_len), dtype=np.int64) attention_mask = np.ones((batch_size, seq_len), dtype=np.int64) token_type_ids = np.zeros((batch_size, seq_len), dtype=np.int64) outputs = session.run(None, { "input_ids": input_ids, "attention_mask": attention_mask, "token_type_ids": token_type_ids }) assert outputs[0].shape == (batch_size, 12) @given(st.integers(min_value=1, max_value=10), st.integers(min_value=16, max_value=256)) @settings(max_examples=100) def test_distilroberta_emotion_input_shapes(self, batch_size, seq_len): """DistilRoBERTa Emotion should accept various input shapes (no token_type_ids)""" import onnxruntime as ort import numpy as np session = ort.InferenceSession( "models/onnx/distilroberta-emotion/model.onnx", providers=['CPUExecutionProvider'] ) input_ids = np.ones((batch_size, seq_len), dtype=np.int64) attention_mask = np.ones((batch_size, seq_len), dtype=np.int64) outputs = session.run(None, { "input_ids": input_ids, "attention_mask": attention_mask }) assert outputs[0].shape == (batch_size, 6) # ============================================================ # CONNECTOR PROPERTY TESTS # ============================================================ class TestConnectorProperties: """Property-based tests for connectors""" @given(st.text(min_size=1, max_size=200)) @settings(max_examples=200) def test_rss_feed_url_validation(self, url): """RSS feed URLs should be valid""" # Simple validation if url.startswith(("http://", "https://")): assert "." in url @given(st.integers(min_value=1, max_value=1000)) @settings(max_examples=200) def test_poll_interval_reasonable(self, interval): """Poll intervals should be reasonable (1 min to 24 hours)""" assert 60 <= interval <= 86400 @given(st.floats(min_value=0.01, max_value=100)) @settings(max_examples=200) def test_rate_limit_reasonable(self, rps): """Rate limits should be reasonable""" assert 0.01 <= rps <= 100 # ============================================================ # CATALOGUE PROPERTY TESTS # ============================================================ class TestCatalogueProperties: """Property-based tests for catalogue""" @given(st.text(min_size=1, max_size=100)) @settings(max_examples=200) def test_source_id_format(self, source_id): """Source IDs should follow format""" # Should not contain special chars except : . - import re assert re.match(r'^[a-zA-Z0-9.:_-]+$', source_id) @given(st.floats(min_value=0, max_value=1)) @settings(max_examples=500) def test_credibility_bounds(self, credibility): """Credibility should be in [0, 1]""" assert 0 <= credibility <= 1 # ============================================================ # INTEGRATION PROPERTY TESTS # ============================================================ class TestIntegrationProperties: """Property-based tests for end-to-end integration""" @pytest.fixture def pipeline(self): from sentiment_engine.nlp.pipeline import NLPProcessingPipeline return NLPProcessingPipeline() @given( st.text(min_size=10, max_size=500), st.sampled_from(["news", "social", "regulatory", "exchange_ann", "on_chain"]), ) @settings(max_examples=100) async def test_pipeline_processes_any_text(self, pipeline, text, source_type): """Pipeline should process any valid text without crashing""" from sentiment_engine.schemas.payload import NormalizedPayload, SourceType, AssetMention await pipeline.initialize() payload = NormalizedPayload( source_id="test", source_type=SourceType(source_type), source_credibility_base=0.5, ingest_ts=1700000000.0, publish_ts=1700000000.0, content_length=len(text), raw_text=text, asset_mentions=[], metadata={} ) result = await pipeline.process(payload) assert isinstance(result, ProcessedItem) assert result.source_id == "test" assert hasattr(result, 'entities') assert hasattr(result, 'sentiment_per_asset') assert hasattr(result, 'events') assert hasattr(result, 'temporal') assert hasattr(result, 'credibility') @given(st.text(min_size=10, max_size=500)) @settings(max_examples=50) async def test_pipeline_latency_reasonable(self, pipeline, text): """Pipeline latency should be reasonable (< 10 seconds)""" from sentiment_engine.schemas.payload import NormalizedPayload, SourceType await pipeline.initialize() payload = NormalizedPayload( source_id="test", source_type=SourceType.NEWS, source_credibility_base=0.5, ingest_ts=1700000000.0, publish_ts=1700000000.0, content_length=len(text), raw_text=text, asset_mentions=[], metadata={} ) result = await pipeline.process(payload) assert result.processing_latency_ms < 10000 # 10 seconds if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"])