fix(charts): validate same-time comparison publications
Generic Container CI/CD / test-build-publish (push) Failing after 2m10s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 2m10s

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:
2026-11-08 03:04:14 +08:00
parent c0dea2aacf
commit b8359ba83c
10 changed files with 261 additions and 8 deletions
+62 -2
View File
@@ -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):