Prompt-only projection rules allowed historical samples to be appended to the current axis. Enforce typed comparison semantics at presentation and report publication, preserving historical read contracts and returning actionable validation errors.
106 lines
5.1 KiB
Python
106 lines
5.1 KiB
Python
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
|