Files
Aether/tests/unit/test_time_range_params.py
fawney19 f3e2f84b38 feat: 统计数据优化 - 支持细粒度时间范围和多维度分析
- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计
- 实现 UTC datetime 范围查询策略,边界数据实时聚合
- 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类
- 新增前端页面:成本分析、性能分析、用户统计
- 新增 TimeRangePicker 组件和统计可视化组件
- 优化 Dashboard 和 Usage 页面支持时间范围筛选

Close #135
2026-02-04 02:04:54 +08:00

275 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import date, datetime, timedelta, timezone
import pytest
from src.services.system.time_range import TimeRangeParams, split_time_range_for_hourly
class TestTimeRangeParams:
# ==================== UTC 转换测试 ====================
def test_to_utc_datetime_range_with_offset(self) -> None:
"""测试带时区偏移的 UTC 转换"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 1),
tz_offset_minutes=480, # UTC+8
)
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc == datetime(2026, 1, 31, 16, 0, tzinfo=timezone.utc)
assert end_utc == datetime(2026, 2, 1, 16, 0, tzinfo=timezone.utc)
def test_to_utc_datetime_range_negative_offset(self) -> None:
"""测试负时区偏移(如 UTC-5"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 1),
tz_offset_minutes=-300, # UTC-5
)
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc == datetime(2026, 2, 1, 5, 0, tzinfo=timezone.utc)
assert end_utc == datetime(2026, 2, 2, 5, 0, tzinfo=timezone.utc)
def test_to_utc_datetime_range_utc(self) -> None:
"""测试 UTC 时区(无偏移)"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 3),
tz_offset_minutes=0,
)
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc == datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
assert end_utc == datetime(2026, 2, 4, 0, 0, tzinfo=timezone.utc) # end_date + 1 day
# ==================== 预设解析测试 ====================
def test_preset_today(self) -> None:
"""测试 today 预设"""
params = TimeRangeParams(preset="today", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date == params.end_date
def test_preset_yesterday(self) -> None:
"""测试 yesterday 预设"""
params = TimeRangeParams(preset="yesterday", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date == params.end_date
today_params = TimeRangeParams(preset="today", tz_offset_minutes=0)
assert today_params.start_date is not None
assert params.start_date == today_params.start_date - timedelta(days=1)
def test_preset_last7days(self) -> None:
"""测试 last7days 预设(包含今天共 7 天)"""
params = TimeRangeParams(preset="last7days", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
days_diff = (params.end_date - params.start_date).days
assert days_diff == 6 # start 到 end 共 7 天
def test_preset_last30days(self) -> None:
"""测试 last30days 预设"""
params = TimeRangeParams(preset="last30days", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
days_diff = (params.end_date - params.start_date).days
assert days_diff == 29 # start 到 end 共 30 天
def test_preset_this_month(self) -> None:
"""测试 this_month 预设"""
params = TimeRangeParams(preset="this_month", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date.day == 1 # 月初
assert params.start_date.month == params.end_date.month
def test_preset_last_month(self) -> None:
"""测试 last_month 预设"""
params = TimeRangeParams(preset="last_month", tz_offset_minutes=0)
assert params.start_date is not None
assert params.end_date is not None
assert params.start_date.day == 1 # 上月初
# end_date 应该是上月最后一天
next_day = params.end_date + timedelta(days=1)
assert next_day.day == 1 # 下一天是本月初
# ==================== 跨年/闰年测试 ====================
def test_cross_year_range(self) -> None:
"""测试跨年日期范围"""
params = TimeRangeParams(
start_date=date(2025, 12, 28),
end_date=date(2026, 1, 3),
tz_offset_minutes=0,
)
assert params.start_date is not None
assert params.end_date is not None
start_utc, end_utc = params.to_utc_datetime_range()
assert start_utc.year == 2025
assert end_utc.year == 2026
days = (params.end_date - params.start_date).days + 1
assert days == 7
def test_leap_year_february(self) -> None:
"""测试闰年 2 月2024 是闰年)"""
params = TimeRangeParams(
start_date=date(2024, 2, 28),
end_date=date(2024, 3, 1),
tz_offset_minutes=0,
)
assert params.start_date is not None
assert params.end_date is not None
start_utc, end_utc = params.to_utc_datetime_range()
days = (params.end_date - params.start_date).days + 1
assert days == 3 # 28, 29, 1
def test_non_leap_year_february(self) -> None:
"""测试非闰年 2 月"""
params = TimeRangeParams(
start_date=date(2025, 2, 28),
end_date=date(2025, 3, 1),
tz_offset_minutes=0,
)
assert params.start_date is not None
assert params.end_date is not None
days = (params.end_date - params.start_date).days + 1
assert days == 2 # 28, 1
# ==================== 验证错误测试 ====================
def test_validate_for_time_series_limit(self) -> None:
"""测试时间序列超过 90 天限制"""
params = TimeRangeParams(
start_date=date(2026, 1, 1),
end_date=date(2026, 4, 5), # 95 days inclusive
tz_offset_minutes=0,
)
with pytest.raises(ValueError):
params.validate_for_time_series()
def test_validate_start_after_end(self) -> None:
"""测试 start_date > end_date 应报错"""
with pytest.raises(ValueError):
TimeRangeParams(
start_date=date(2026, 2, 10),
end_date=date(2026, 2, 1),
tz_offset_minutes=0,
)
def test_validate_max_days_exceeded(self) -> None:
"""测试超过 365 天限制"""
with pytest.raises(ValueError):
TimeRangeParams(
start_date=date(2025, 1, 1),
end_date=date(2026, 2, 1), # > 365 days
tz_offset_minutes=0,
)
def test_validate_hour_granularity_multi_day(self) -> None:
"""测试小时粒度不支持多天"""
with pytest.raises(ValueError):
TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 2),
granularity="hour",
tz_offset_minutes=0,
)
def test_validate_missing_dates_and_preset(self) -> None:
"""测试缺少日期和预设应报错"""
with pytest.raises(ValueError):
TimeRangeParams(tz_offset_minutes=0)
# ==================== 完整 UTC 日期分割测试 ====================
def test_get_complete_utc_dates_aligned(self) -> None:
"""测试 UTC 对齐时无头尾边界"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 3),
tz_offset_minutes=0, # UTC 对齐
)
assert params.start_date is not None
assert params.end_date is not None
complete_dates, head, tail = params.get_complete_utc_dates()
assert len(complete_dates) == 3
assert head is None
assert tail is None
def test_get_complete_utc_dates_with_offset(self) -> None:
"""测试带偏移时产生头尾边界"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 3),
tz_offset_minutes=480, # UTC+8
)
assert params.start_date is not None
assert params.end_date is not None
complete_dates, head, tail = params.get_complete_utc_dates()
# UTC+8 的 2/1 00:00 = UTC 1/31 16:00会产生头边界
assert head is not None or tail is not None or len(complete_dates) < 3
# ==================== 本地日期小时映射测试 ====================
def test_get_local_day_hours(self) -> None:
"""测试本地日期到 UTC 小时映射"""
params = TimeRangeParams(
start_date=date(2026, 2, 1),
end_date=date(2026, 2, 2),
tz_offset_minutes=480, # UTC+8
)
assert params.start_date is not None
assert params.end_date is not None
local_days = params.get_local_day_hours()
assert len(local_days) == 2
for local_date, start_utc, end_utc in local_days:
assert isinstance(local_date, date)
assert isinstance(start_utc, datetime)
assert isinstance(end_utc, datetime)
# 每天应该正好是 24 小时
assert (end_utc - start_utc).total_seconds() == 24 * 3600
class TestSplitTimeRangeForHourly:
"""测试小时级时间范围分割"""
def test_aligned_hours(self) -> None:
"""测试整点对齐的时间范围"""
start = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 3, 0, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is None
assert tail is None
assert len(complete) == 3
def test_with_head_fragment(self) -> None:
"""测试带头部碎片"""
start = datetime(2026, 2, 1, 0, 30, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 3, 0, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is not None
assert head[0] == start
assert head[1] == datetime(2026, 2, 1, 1, 0, tzinfo=timezone.utc)
assert len(complete) == 2 # 1:00, 2:00
def test_with_tail_fragment(self) -> None:
"""测试带尾部碎片"""
start = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 2, 30, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is None
assert tail is not None
assert tail[0] == datetime(2026, 2, 1, 2, 0, tzinfo=timezone.utc)
assert tail[1] == end
assert len(complete) == 2 # 0:00, 1:00
def test_with_both_fragments(self) -> None:
"""测试同时带头尾碎片"""
start = datetime(2026, 2, 1, 0, 15, tzinfo=timezone.utc)
end = datetime(2026, 2, 1, 2, 45, tzinfo=timezone.utc)
head, complete, tail = split_time_range_for_hourly(start, end)
assert head is not None
assert tail is not None
assert len(complete) == 1 # 只有 1:00-2:00 是完整的