fix(charts): validate same-time comparison publications
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.
This commit is contained in:
@@ -617,9 +617,10 @@ async def test_replay_investigation_can_publish_chart_and_propose_work_order():
|
||||
result = await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
||||
command={"action": "open_chart", "chart_id": "pressure", "evidence_refs": [evidence.evidence_id],
|
||||
"chart": {"title": "压力", "chart_type": "line", "x_data": ["19:45", "20:00"],
|
||||
"chart": {"title": "压力", "chart_type": "time_comparison", "x_data": ["19:45", "20:00"],
|
||||
"y_axis_name": "压力 m", "series": [
|
||||
{"name": "当前", "type": "line", "data": [27., 26.]},
|
||||
{"name": "当前", "type": "line", "data": [27., 26.],
|
||||
"sample_times": ["2026-10-10T19:45:00+08:00", "2026-10-10T20:00:00+08:00"]},
|
||||
{"name": "前一天", "type": "scatter", "data": [None, 28.],
|
||||
"sample_times": [None, "2026-10-09T20:00:00+08:00"]},
|
||||
]}},
|
||||
@@ -637,6 +638,65 @@ async def test_replay_investigation_can_publish_chart_and_propose_work_order():
|
||||
assert repository.action["action_id"] == str(action.action_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("destination", ["presentation", "report"])
|
||||
@pytest.mark.parametrize("defect", [None, "legacy_type", "dates_on_axis", "missing_times", "wrong_slot", "future_day"])
|
||||
async def test_comparison_chart_publication_is_validated_at_both_write_boundaries(destination, defect):
|
||||
now = datetime(2026, 10, 10, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repository = RunTaskRepository(task, _active_run(task, now))
|
||||
service = OperationalTaskService(repository, context, set(), clock=lambda: now)
|
||||
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||
source="query_scada_readings", source_ref="mcp:comparison", queried_at=now,
|
||||
origin="synthetic", data={"items": []}, truncated=False,
|
||||
))
|
||||
chart = {"title": "同期对照", "chart_type": "time_comparison", "x_data": ["08:00"], "series": [
|
||||
{"name": "当前", "data": [25.], "sample_times": ["2026-10-10T08:00:00+08:00"]},
|
||||
{"name": "历史", "type": "scatter", "data": [26.], "sample_times": ["2026-10-09T08:00:00+08:00"]},
|
||||
]}
|
||||
if defect == "legacy_type":
|
||||
chart["chart_type"] = "line"
|
||||
elif defect == "dates_on_axis":
|
||||
chart["x_data"] = ["10-10 08:00"]
|
||||
elif defect == "missing_times":
|
||||
del chart["series"][1]["sample_times"]
|
||||
elif defect == "wrong_slot":
|
||||
chart["series"][1]["sample_times"] = ["2026-10-09T07:45:00+08:00"]
|
||||
elif defect == "future_day":
|
||||
chart["series"][1]["sample_times"] = ["2026-10-11T08:00:00+08:00"]
|
||||
events_before = len(repository.events)
|
||||
|
||||
async def publish():
|
||||
if destination == "presentation":
|
||||
await service.publish_presentation_event(task["task_id"], TaskPresentationEventCommand(
|
||||
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
|
||||
command={"action": "open_chart", "chart_id": "comparison", "evidence_refs": [evidence.evidence_id],
|
||||
"chart": chart},
|
||||
))
|
||||
else:
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report={
|
||||
**_current_report(), "evidence_refs": [evidence.evidence_id], "ui_blocks": [{
|
||||
"evidence_refs": [evidence.evidence_id], "block": {
|
||||
"version": "answer-ui@1", "block_id": "comparison", "fallback_text": "同期对照",
|
||||
"spec": {"root": "chart", "elements": {"chart": {
|
||||
"type": "Chart", "children": [], "props": {**chart, "source": "合成数据"},
|
||||
}}},
|
||||
},
|
||||
}],
|
||||
},
|
||||
))
|
||||
if defect is None:
|
||||
await publish()
|
||||
assert len(repository.events) > events_before
|
||||
else:
|
||||
with pytest.raises(DomainError) as error:
|
||||
await publish()
|
||||
assert error.value.code == "TIME_COMPARISON_INVALID"
|
||||
assert len(repository.events) == events_before
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("operation", ["propose", "approve", "submit"])
|
||||
async def test_replay_cannot_propose_approve_or_submit_device_commands(operation):
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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
|
||||
Reference in New Issue
Block a user