"""analytics.py DTO 单元测试。 覆盖 ``TimeSeriesPoint`` / ``MessageAnalytics`` / ``MessageDistribution`` / ``SessionAnalytics`` / ``DeliveryAnalytics`` / ``DeliveryLatencyDistribution`` / ``DeliveryFunnel`` / ``AccountAnalyticsQuery`` / ``AccountActivityStat`` / ``AccountAnalyticsResult`` / ``PeerAnalyticsQuery`` / ``PeerActivityStat`` / ``PeerAnalyticsResult`` / ``ContentReviewAnalyticsQuery`` / ``ContentReviewAnalyticsResult`` 的字段赋值、默认值、不可变语义、 ``__post_init__`` 校验逻辑与 ``to_dict`` 序列化方法。 """ from __future__ import annotations import dataclasses from datetime import datetime, timedelta, timezone import pytest from yuxi.channels.contract.dtos.analytics import ( MAX_LIMIT, MAX_TIME_RANGE_DAYS, AccountActivityStat, AccountAnalyticsQuery, AccountAnalyticsResult, ContentReviewAnalyticsQuery, ContentReviewAnalyticsResult, DeliveryAnalytics, DeliveryFunnel, DeliveryLatencyDistribution, MessageAnalytics, MessageDistribution, PeerActivityStat, PeerAnalyticsQuery, PeerAnalyticsResult, SessionAnalytics, TimeSeriesPoint, ) from yuxi.channels.contract.dtos.common import CategoryStat, TrendDataPoint from yuxi.channels.contract.errors import ValidationError pytestmark = pytest.mark.unit def _aware(days_ago: int) -> datetime: return datetime.now(timezone.utc) - timedelta(days=days_ago) @pytest.mark.unit class TestTimeSeriesPoint: """时间序列数据点 DTO 测试。""" def test_fields_are_assigned(self): # Arrange # Act point = TimeSeriesPoint(timestamp="2024-01-01T00:00:00Z", value=42) # Assert assert point.timestamp == "2024-01-01T00:00:00Z" assert point.value == 42 def test_to_dict(self): # Arrange point = TimeSeriesPoint(timestamp="2024-01-01", value=10) # Act result = point.to_dict() # Assert assert result == {"timestamp": "2024-01-01", "value": 10} def test_is_frozen(self): # Arrange point = TimeSeriesPoint(timestamp="t", value=1) # Act / Assert with pytest.raises(dataclasses.FrozenInstanceError): point.value = 2 # type: ignore[misc] @pytest.mark.unit class TestMessageAnalytics: """消息深度分析结果 DTO 测试。""" def test_fields_are_assigned(self): # Arrange ts = (TimeSeriesPoint(timestamp="t1", value=5),) # Act analytics = MessageAnalytics( total=100, timeseries=ts, by_channel={"wechat": 60}, by_role={"user": 40, "assistant": 60}, by_delivery_status={"sent": 90}, ) # Assert assert analytics.total == 100 assert analytics.timeseries == ts assert analytics.by_channel == {"wechat": 60} def test_to_dict_serializes_timeseries(self): # Arrange ts = (TimeSeriesPoint(timestamp="t1", value=5), TimeSeriesPoint(timestamp="t2", value=10)) analytics = MessageAnalytics( total=15, timeseries=ts, by_channel={}, by_role={}, by_delivery_status={}, ) # Act result = analytics.to_dict() # Assert assert result["total"] == 15 assert result["timeseries"] == [ {"timestamp": "t1", "value": 5}, {"timestamp": "t2", "value": 10}, ] def test_is_frozen(self): # Arrange analytics = MessageAnalytics( total=0, timeseries=(), by_channel={}, by_role={}, by_delivery_status={} ) # Act / Assert with pytest.raises(dataclasses.FrozenInstanceError): analytics.total = 1 # type: ignore[misc] @pytest.mark.unit class TestMessageDistribution: """消息类型分布结果 DTO 测试。""" def test_fields_are_assigned(self): # Act dist = MessageDistribution( total=100, by_type={"text": 80, "card": 20}, by_type_percent={"text": 80.0, "card": 20.0}, ) # Assert assert dist.total == 100 assert dist.by_type["text"] == 80 def test_to_dict(self): # Arrange dist = MessageDistribution(total=50, by_type={"text": 50}, by_type_percent={"text": 100.0}) # Act result = dist.to_dict() # Assert assert result == {"total": 50, "by_type": {"text": 50}, "by_type_percent": {"text": 100.0}} @pytest.mark.unit class TestSessionAnalytics: """会话分析结果 DTO 测试。""" def test_fields_are_assigned(self): # Arrange ts = (TimeSeriesPoint(timestamp="t1", value=3),) # Act analytics = SessionAnalytics( total=10, timeseries=ts, by_channel={"wechat": 10}, by_is_temporary={"true": 2, "false": 8}, by_message_count_bucket={"0-10": 5, "11-50": 5}, ) # Assert assert analytics.total == 10 assert analytics.by_is_temporary["true"] == 2 def test_to_dict_serializes_timeseries(self): # Arrange analytics = SessionAnalytics( total=0, timeseries=(TimeSeriesPoint(timestamp="t", value=1),), by_channel={}, by_is_temporary={}, by_message_count_bucket={}, ) # Act result = analytics.to_dict() # Assert assert result["timeseries"] == [{"timestamp": "t", "value": 1}] @pytest.mark.unit class TestDeliveryAnalytics: """投递链路分析结果 DTO 测试。""" def test_fields_are_assigned(self): # Act analytics = DeliveryAnalytics( total=200, success_rate=95.0, failure_rate=5.0, retry_distribution={"0": 180, "1": 15}, avg_latency_ms=120.5, ) # Assert assert analytics.total == 200 assert analytics.avg_latency_ms == 120.5 def test_avg_latency_ms_can_be_none(self): # Act analytics = DeliveryAnalytics( total=0, success_rate=0.0, failure_rate=0.0, retry_distribution={}, avg_latency_ms=None ) # Assert assert analytics.avg_latency_ms is None def test_to_dict_preserves_none(self): # Arrange analytics = DeliveryAnalytics( total=0, success_rate=0.0, failure_rate=0.0, retry_distribution={}, avg_latency_ms=None ) # Act result = analytics.to_dict() # Assert assert result["avg_latency_ms"] is None @pytest.mark.unit class TestDeliveryLatencyDistribution: """投递延迟分布结果 DTO 测试。""" def test_fields_are_assigned(self): # Act dist = DeliveryLatencyDistribution( p50_ms=50.0, p90_ms=200.0, p99_ms=500.0, max_ms=1000.0, histogram={"0-100ms": 80, "100-500ms": 20}, ) # Assert assert dist.p50_ms == 50.0 assert dist.histogram["0-100ms"] == 80 def test_percentiles_can_be_none(self): # Act dist = DeliveryLatencyDistribution( p50_ms=None, p90_ms=None, p99_ms=None, max_ms=None, histogram={} ) # Assert assert dist.p50_ms is None def test_to_dict_preserves_none(self): # Arrange dist = DeliveryLatencyDistribution( p50_ms=None, p90_ms=100.0, p99_ms=None, max_ms=None, histogram={} ) # Act result = dist.to_dict() # Assert assert result["p50_ms"] is None assert result["p90_ms"] == 100.0 @pytest.mark.unit class TestDeliveryFunnel: """投递漏斗分析结果 DTO 测试。""" def test_fields_are_assigned(self): # Act funnel = DeliveryFunnel( enter=100, sent=90, suppressed=5, failed=4, dead=1, conversion_rate={"sent": 90.0, "suppressed": 5.0, "failed": 4.0, "dead": 1.0}, ) # Assert assert funnel.enter == 100 assert funnel.conversion_rate["sent"] == 90.0 def test_conversion_rate_values_can_be_none(self): # Act funnel = DeliveryFunnel( enter=0, sent=0, suppressed=0, failed=0, dead=0, conversion_rate={"sent": None}, ) # Assert assert funnel.conversion_rate["sent"] is None @pytest.mark.unit class TestAccountAnalyticsQuery: """账户活跃度分析查询 DTO 测试。""" def test_fields_are_assigned_with_defaults(self): # Arrange start = _aware(7) end = _aware(0) # Act query = AccountAnalyticsQuery(start_time=start, end_time=end) # Assert assert query.granularity == "day" assert query.channel_type is None assert query.limit == 100 def test_is_frozen(self): # Arrange query = AccountAnalyticsQuery(start_time=_aware(7), end_time=_aware(0)) # Act / Assert with pytest.raises(dataclasses.FrozenInstanceError): query.limit = 200 # type: ignore[misc] def test_start_must_be_before_end(self): # Arrange start = _aware(0) end = _aware(7) # Act / Assert with pytest.raises(ValidationError) as exc_info: AccountAnalyticsQuery(start_time=start, end_time=end) assert exc_info.value.field == "time_range" def test_time_range_must_not_exceed_max(self): # Arrange start = _aware(MAX_TIME_RANGE_DAYS + 1) end = _aware(0) # Act / Assert with pytest.raises(ValidationError) as exc_info: AccountAnalyticsQuery(start_time=start, end_time=end) assert exc_info.value.field == "time_range" def test_invalid_granularity_raises(self): # Arrange # Act / Assert with pytest.raises(ValidationError) as exc_info: AccountAnalyticsQuery( start_time=_aware(7), end_time=_aware(0), granularity="minute", # type: ignore[arg-type] ) assert exc_info.value.field == "granularity" def test_limit_below_min_raises(self): # Act / Assert with pytest.raises(ValidationError) as exc_info: AccountAnalyticsQuery(start_time=_aware(7), end_time=_aware(0), limit=0) assert exc_info.value.field == "limit" def test_limit_above_max_raises(self): # Act / Assert with pytest.raises(ValidationError) as exc_info: AccountAnalyticsQuery(start_time=_aware(7), end_time=_aware(0), limit=MAX_LIMIT + 1) assert exc_info.value.field == "limit" @pytest.mark.unit class TestAccountActivityStat: """账户活跃统计 DTO 测试。""" def test_fields_are_assigned(self): # Act stat = AccountActivityStat( account_id="acc-1", channel_type="wechat", message_count=100, session_count=20, active_days=5, avg_daily_messages=20.0, ) # Assert assert stat.account_id == "acc-1" assert stat.avg_daily_messages == 20.0 def test_to_dict(self): # Arrange stat = AccountActivityStat( account_id="a1", channel_type="wechat", message_count=10, session_count=2, active_days=1, avg_daily_messages=10.0, ) # Act result = stat.to_dict() # Assert assert result["account_id"] == "a1" assert result["avg_daily_messages"] == 10.0 @pytest.mark.unit class TestAccountAnalyticsResult: """账户活跃度分析结果 DTO 测试。""" def test_to_dict_maps_trend_value_to_active_accounts(self): # Arrange stat = AccountActivityStat( account_id="a1", channel_type="wechat", message_count=10, session_count=2, active_days=1, avg_daily_messages=10.0, ) trend = (TrendDataPoint(timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc), value=5),) result_dto = AccountAnalyticsResult(by_account=(stat,), trend=trend) # Act result = result_dto.to_dict() # Assert assert result["trend"][0]["active_accounts"] == 5 assert "timestamp" in result["trend"][0] assert result["by_account"][0]["account_id"] == "a1" @pytest.mark.unit class TestPeerAnalyticsQuery: """对端活跃度分析查询 DTO 测试。""" def test_fields_are_assigned_with_defaults(self): # Act query = PeerAnalyticsQuery(start_time=_aware(7), end_time=_aware(0)) # Assert assert query.limit == 100 assert query.channel_type is None def test_start_must_be_before_end(self): # Act / Assert with pytest.raises(ValidationError) as exc_info: PeerAnalyticsQuery(start_time=_aware(0), end_time=_aware(7)) assert exc_info.value.field == "time_range" def test_limit_below_min_raises(self): # Act / Assert with pytest.raises(ValidationError) as exc_info: PeerAnalyticsQuery(start_time=_aware(7), end_time=_aware(0), limit=0) assert exc_info.value.field == "limit" @pytest.mark.unit class TestPeerActivityStat: """对端活跃统计 DTO 测试。""" def test_to_dict_formats_datetimes(self): # Arrange ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) stat = PeerActivityStat( peer_id="p1", channel_type="wechat", message_count=10, session_count=2, first_seen=ts, last_seen=ts, ) # Act result = stat.to_dict() # Assert assert result["first_seen"] is not None assert result["last_seen"] is not None def test_to_dict_preserves_none_datetimes(self): # Arrange stat = PeerActivityStat( peer_id="p1", channel_type="wechat", message_count=0, session_count=0, first_seen=None, last_seen=None, ) # Act result = stat.to_dict() # Assert assert result["first_seen"] is None assert result["last_seen"] is None @pytest.mark.unit class TestPeerAnalyticsResult: """对端活跃度分析结果 DTO 测试。""" def test_to_dict(self): # Arrange stat = PeerActivityStat( peer_id="p1", channel_type="wechat", message_count=10, session_count=2, first_seen=None, last_seen=None, ) result_dto = PeerAnalyticsResult(top_active=(stat,)) # Act result = result_dto.to_dict() # Assert assert result["top_active"][0]["peer_id"] == "p1" @pytest.mark.unit class TestContentReviewAnalyticsQuery: """内容审核分析查询 DTO 测试。""" def test_fields_are_assigned_with_defaults(self): # Act query = ContentReviewAnalyticsQuery(start_time=_aware(7), end_time=_aware(0)) # Assert assert query.granularity == "day" def test_invalid_granularity_raises(self): # Act / Assert with pytest.raises(ValidationError) as exc_info: ContentReviewAnalyticsQuery( start_time=_aware(7), end_time=_aware(0), granularity="minute", # type: ignore[arg-type] ) assert exc_info.value.field == "granularity" def test_start_must_be_before_end(self): # Act / Assert with pytest.raises(ValidationError) as exc_info: ContentReviewAnalyticsQuery(start_time=_aware(0), end_time=_aware(7)) assert exc_info.value.field == "time_range" @pytest.mark.unit class TestContentReviewAnalyticsResult: """内容审核分析结果 DTO 测试。""" def test_to_dict_computes_rate(self): # Arrange by_category = (CategoryStat(category="politics", count=10),) trend = ( { "timestamp": datetime(2024, 1, 1, tzinfo=timezone.utc), "reviewed": 100, "blocked": 10, }, ) result_dto = ContentReviewAnalyticsResult( total_reviews=100, block_count=10, block_rate=10.0, by_category=by_category, trend=trend, ) # Act result = result_dto.to_dict() # Assert assert result["by_category"][0]["rate"] == 10.0 assert result["trend"][0]["reviewed"] == 100 def test_to_dict_rate_zero_when_no_reviews(self): # Arrange result_dto = ContentReviewAnalyticsResult( total_reviews=0, block_count=0, block_rate=0.0, by_category=(CategoryStat(category="spam", count=0),), trend=(), ) # Act result = result_dto.to_dict() # Assert assert result["by_category"][0]["rate"] == 0.0