from copy import deepcopy from datetime import datetime, timedelta, timezone import pytest from app.domain.domain_errors import DomainError from app.domain.schemas.operational_task import TaskSafeChart from app.services.time_comparison_chart import validate_time_comparison_chart def comparison_chart(start="2026-07-16T05:00:00+08:00", interval=15): times = [datetime.fromisoformat(start) + timedelta(minutes=index * interval) for index in range(13)] return {"title": "七天同期对比", "chart_type": "time_comparison", "x_data": [at.strftime("%H:%M") for at in times], "series": [ {"name": "当前", "type": "line", "data": [25.] * 13, "sample_times": [at.isoformat() for at in times]}, *[{"name": f"前{day}天", "type": "scatter", "data": [None] * 10 + [26.] * 3, "sample_times": [None] * 10 + [(at - timedelta(days=day)).isoformat() for at in times[10:]]} for day in range(1, 8)], ]} @pytest.mark.parametrize("start,interval", [ ("2026-07-16T05:00:00+08:00", 15), ("2026-07-16T23:00:00+08:00", 15), ("2026-07-16T05:00:00+08:00", 5), ]) def test_same_time_comparison_preserves_seven_sparse_days_and_sampling_intervals(start, interval): chart = comparison_chart(start, interval) # A missing current observation is still a real empty slot. chart["series"][0]["data"][11] = None chart["series"][0]["sample_times"][11] = None # UTC and local-offset spellings refer to the same real historical instant. chart["series"][1]["sample_times"][12] = ( datetime.fromisoformat(chart["series"][1]["sample_times"][12]) .astimezone(timezone.utc).isoformat().replace("+00:00", "Z") ) before = deepcopy(chart) validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json")) assert chart == before @pytest.mark.parametrize("defect", [ "appended_history", "duplicate_slots", "wrong_slot", "missing_times", "missing_point_time", "mixed_dates", "same_day", "future_day", "bar_series", "only_line", "no_current_observations", ]) def test_special_comparison_rejects_misleading_projections(defect): chart = comparison_chart() history = chart["series"][1] if defect == "appended_history": chart["x_data"] = [f"07-16 {slot}" for slot in chart["x_data"]] + ["07-15 07:30", "07-15 07:45", "07-15 08:00"] for series in chart["series"]: series["data"] += [None] * 3 series["sample_times"] += [None] * 3 elif defect == "duplicate_slots": chart["x_data"][11] = chart["x_data"][10] elif defect == "wrong_slot": history["sample_times"][10] = "2026-07-15T07:45:00+08:00" elif defect == "missing_times": del history["sample_times"] elif defect == "missing_point_time": history["sample_times"][10] = None elif defect == "mixed_dates": history["sample_times"][10] = "2026-07-14T07:30:00+08:00" elif defect in {"same_day", "future_day"}: day = 16 if defect == "same_day" else 17 history["sample_times"] = [at.replace("07-15", f"07-{day}") if at else None for at in history["sample_times"]] elif defect == "bar_series": history["type"] = "bar" elif defect == "only_line": chart["series"] = chart["series"][:1] elif defect == "no_current_observations": chart["series"][0]["data"] = chart["series"][0]["sample_times"] = [None] * 13 with pytest.raises(DomainError) as error: validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json")) assert error.value.code == "TIME_COMPARISON_INVALID" def test_ordinary_charts_skip_comparison_validation_and_old_mixed_charts_remain_readable(): chart = {"title": "普通趋势", "chart_type": "line", "x_data": ["07-16 08:00"], "series": [{"name": "当前", "data": [25.]}]} validate_time_comparison_chart(chart) chart["chart_type"] = "bar" validate_time_comparison_chart(chart) chart["chart_type"] = "line" chart["series"].append({"name": "旧版散点", "type": "scatter", "data": [26.]}) assert TaskSafeChart.model_validate(chart).model_dump(mode="json") == chart with pytest.raises(DomainError, match="time_comparison"): validate_time_comparison_chart(chart) @pytest.mark.parametrize("labels,times", [ (["07:45"], ["9999-12-31T23:45:00Z"]), (["08:00"], ["0001-01-01T00:00:00+23:59"]), (["23:45", "00:15"], [None, "0001-01-01T00:15:00+08:00"]), ]) def test_calendar_range_overflow_returns_a_domain_rejection(labels, times): chart = {"title": "日期边界", "chart_type": "time_comparison", "x_data": labels, "series": [ {"name": "当前", "type": "line", "data": [1. if at else None for at in times], "sample_times": times}, {"name": "历史缺测", "type": "scatter", "data": [None] * len(labels), "sample_times": [None] * len(labels)}, ]} with pytest.raises(DomainError, match="日期范围") as error: validate_time_comparison_chart(TaskSafeChart.model_validate(chart).model_dump(mode="json")) assert error.value.code == "TIME_COMPARISON_INVALID" assert error.value.status == 422