Files
next-tjwater-server/tests/unit/test_scada_presentation_coverage.py
T
jiang 1892b1d1f0
Generic Container CI/CD / test-build-publish (push) Successful in 4m10s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m10s
fix(scada): preserve observation fields in chart evidence
2026-11-12 12:22:07 +08:00

472 lines
25 KiB
Python

"""SCADA display coverage is independent of whether a device supports a conclusion."""
from datetime import datetime, timedelta, timezone
from copy import deepcopy
from uuid import uuid4
import pytest
from app.domain.domain_errors import DomainError
from app.domain.schemas.operational_data import TaskDataSnapshot
from app.domain.schemas.operational_task import AgentArtifactsCommand, AgentRunCommand, TaskPresentationEventCommand
from app.services.operational_tasks import OperationalTaskService
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
DEVICES = ["486033", "542017", "541061", "524529"]
END = datetime(2026, 7, 16, tzinfo=timezone.utc)
START = END - timedelta(hours=3)
@pytest.mark.anyio
async def test_value_only_detail_evidence_cannot_claim_missing_observations():
from app.services.scada_presentation_coverage import verify_chart
task, repository, _, evidence_id = await investigation()
detail = deepcopy(repository.evidence[evidence_id])
detail["evidence_id"] = "detail-values"
rows = detail["data_snapshot"]["data"]["data"]["readings"]
detail["data_snapshot"]["data"]["data"] = [
{"device_id": row["device_id"], "time": row["time"], "value": row["monitored_value"]}
for row in rows
]
value = chart(devices=["524529"])
value["series"][0].update(data=[None] * 13, sample_times=[None] * 13)
with pytest.raises(DomainError) as failure:
verify_chart(task, value, [repository.evidence[evidence_id], detail], ["detail-values"])
assert failure.value.code == "SCADA_CHART_EVIDENCE_REQUIRED"
@pytest.mark.anyio
@pytest.mark.parametrize("field", ["monitored_value", "cleaned_value"])
async def test_detail_fields_do_not_discard_other_observed_fields(field):
from app.services.scada_presentation_coverage import verify_chart
task, repository, _, evidence_id = await investigation()
detail = deepcopy(repository.evidence[evidence_id])
detail["evidence_id"] = "detail-cleaned"
rows = detail["data_snapshot"]["data"]["data"]["readings"]
detail["data_snapshot"]["data"]["data"] = [
{"device_id": row["device_id"], "time": row["time"], "value": row["cleaned_value"] + 10,
"cleaned_value": row["cleaned_value"] + 10} for row in rows
]
value = chart(devices=["524529"])
value["scada"]["value_field"] = field
if field == "cleaned_value":
value["series"][0]["data"] = [point + 10 for point in value["series"][0]["data"]]
result = verify_chart(task, value, [repository.evidence[evidence_id], detail], [evidence_id, "detail-cleaned"])
assert result["gaps"] == []
@pytest.fixture
def anyio_backend():
return "asyncio"
async def investigation():
context = _context()
task = _task_document(context, END, lease=END + timedelta(minutes=1))
task["workflow"] = "scada_anomaly"
repository = TaskRepository(task, _active_run(task, END))
service = OperationalTaskService(repository, context, set(), clock=lambda: END)
readings = []
for day in range(8):
slots = range(13) if day == 0 else range(10, 13)
for slot in slots:
for device in DEVICES:
readings.append({"device_id": device, "time": (START + timedelta(minutes=15 * slot, days=-day)).isoformat(),
"monitored_value": float(slot + 1), "cleaned_value": float(slot + 1)})
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
source="query_scada_readings", source_ref="mcp:observations", queried_at=END,
origin="system_data", truncated=False, data={
"window": {"start": (START - timedelta(days=7)).isoformat(), "end": END.isoformat()},
"data": {"devices": [{"device_id": device, "device_type": "pipe_flow", "measurement_unit": "m3/h",
"transmission_frequency": "0:15:00"} for device in DEVICES],
"readings": readings, "coverage": {"mode": "all_scada", "query_complete": True}},
},
))
return task, repository, service, evidence.evidence_id
def coverage(comparison_devices):
slots = [(START + timedelta(minutes=15 * i)).isoformat() for i in range(13)]
common = {"coverage": {device: {"covered": slots} for device in DEVICES}, "metric": "flow", "window_start": START.isoformat(), "window_end": END.isoformat(), "gaps": []}
return {"version": 1, "targets": DEVICES.copy(), "charts": {
"overview": {**common, "role": "current", "device_ids": DEVICES.copy()},
**{f"comparison-{device}": {**common, "role": "comparison", "device_ids": [device],
"coverage": {device: {"covered": slots}}}
for device in comparison_devices},
}}
def final_report(evidence_id):
return {"stage": "final", "outcome": "concluded", "title": "调查结果",
"current_conclusion": "524529 仅作参考", "conclusion_boundary": "待现场核验",
"evidence_refs": [evidence_id], "data_gaps": [], "scada_device_ids": DEVICES}
@pytest.mark.anyio
async def test_final_rejects_four_target_devices_with_three_comparisons():
task, repository, service, evidence_id = await investigation()
repository.scada_presentations = {(task["task_id"], repository.run["run_id"]): coverage(DEVICES[:3])}
with pytest.raises(DomainError) as failure:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
))
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
assert "524529" in failure.value.message
assert not repository.reports
@pytest.mark.anyio
async def test_final_accepts_all_four_devices():
task, repository, service, evidence_id = await investigation()
repository.scada_presentations = {(task["task_id"], repository.run["run_id"]): coverage(DEVICES)}
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
))
assert result.report_revision == 1
def chart(role="current", devices=None):
devices = DEVICES if devices is None else devices
comparison = role == "comparison"
series = []
for device in devices:
for day in (range(8) if comparison else [0]):
slots = range(13) if day == 0 else range(10, 13)
series.append({"name": f"{device} {day}", "device_id": device, "day_offset": day,
"type": "scatter" if day else "line",
"data": [float(index + 1) if index in slots else None for index in range(13)],
"sample_times": [(START + timedelta(minutes=15 * index, days=-day)).isoformat()
if index in slots else None for index in range(13)]})
return {"title": "流量观测", "chart_type": "time_comparison" if comparison else "line",
"x_data": [(START + timedelta(minutes=15 * index, hours=8)).strftime("%H:%M") for index in range(13)],
"series": series, "scada": {"role": role, "metric": "flow", "unit": "m3/h",
"window_start": START.isoformat(), "window_end": END.isoformat()}}
async def publish(task, repository, service, evidence_id, chart_id, data):
command = TaskPresentationEventCommand(
request_id=uuid4(), run_id=repository.run["run_id"], source_session_id="session-1",
command={"action": "open_chart", "chart_id": chart_id, "chart": data, "evidence_refs": [evidence_id]},
)
return await service.publish_presentation_event(task["task_id"], command)
@pytest.mark.anyio
async def test_publications_require_complete_targets_and_retry_without_a_browser():
task, repository, service, evidence_id = await investigation()
await publish(task, repository, service, evidence_id, "overview", chart())
for device in DEVICES[:3]:
await publish(task, repository, service, evidence_id, device, chart("comparison", [device]))
interim = {**final_report(evidence_id), "stage": "interim"}
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=interim,
))
final = AgentArtifactsCommand(request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id))
with pytest.raises(DomainError) as failure:
await service.publish_artifacts(task["task_id"], final)
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
assert failure.value.refs == ["524529"]
await publish(task, repository, service, evidence_id, "524529", chart("comparison", ["524529"]))
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": "close_chart", "chart_id": "524529"},
))
result = await service.publish_artifacts(task["task_id"], final)
assert result.task.latest_report.report.stage == "final"
# Neither retained event history nor a browser acknowledgement is required.
repository.events.clear()
complete = await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
))
assert complete.latest_run.status == "succeeded"
@pytest.mark.anyio
async def test_successful_completion_rechecks_coverage_after_final_report():
task, repository, service, evidence_id = await investigation()
repository.scada_presentations[(task["task_id"], repository.run["run_id"])] = coverage(DEVICES)
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
))
# Replacing a chart cannot erase its fourth target from the required set.
await publish(task, repository, service, evidence_id, "overview", chart(devices=DEVICES[:3]))
with pytest.raises(DomainError) as failure:
await service.complete_run(task["task_id"], repository.run["run_id"], AgentRunCommand(
request_id=uuid4(), expected_revision=repository.run["revision"], source_session_id="session-1",
))
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
assert "524529" in failure.value.message
assert repository.run["status"] == "running"
@pytest.mark.anyio
@pytest.mark.parametrize("missing_current", [False, True])
async def test_missing_observations_keep_the_object_and_server_derived_gaps(missing_current):
task, repository, service, evidence_id = await investigation()
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
data["readings"] = [row for row in data["readings"] if row["device_id"] != "524529"
or not missing_current and datetime.fromisoformat(row["time"]) >= START]
value = chart("comparison", ["524529"])
for series in value["series"]:
if missing_current or series["day_offset"]:
series["data"] = [None] * 13
series["sample_times"] = [None] * 13
await publish(task, repository, service, evidence_id, "524529", value)
saved = repository.events[-1]["metadata"]["command"]["chart"]
assert len(saved["scada"]["gaps"]) == (8 if missing_current else 7)
assert sum(gap["missing_count"] for gap in saved["scada"]["gaps"]) == (34 if missing_current else 21)
document = await repository.get_scada_presentation(task["task_id"], repository.run["run_id"])
assert document["targets"] == ["524529"]
assert document["charts"]["524529"]["device_ids"] == ["524529"]
@pytest.mark.anyio
@pytest.mark.parametrize("defect,code", [
("null_existing_value", "SCADA_CHART_DATA_MISMATCH"),
("invented_value", "SCADA_CHART_DATA_MISMATCH"),
("wrong_window", "SCADA_CHART_WINDOW_INVALID"),
("missing_day", "SCADA_CHART_INVALID"),
("wrong_unit", "SCADA_CHART_INVALID"),
("unqueried_history", "SCADA_CHART_EVIDENCE_REQUIRED"),
])
async def test_scada_chart_cannot_fake_coverage(defect, code):
task, repository, service, evidence_id = await investigation()
value = chart("comparison", ["524529"])
if defect == "null_existing_value":
value["series"][1]["data"][10] = None
value["series"][1]["sample_times"][10] = None
elif defect == "invented_value":
value["series"][0]["data"][0] = 999.
elif defect == "wrong_window":
value["scada"]["window_end"] = (END - timedelta(minutes=15)).isoformat()
elif defect == "missing_day":
value["series"].pop()
elif defect == "wrong_unit":
value["scada"]["unit"] = "m"
elif defect == "unqueried_history":
repository.evidence[evidence_id]["data_snapshot"]["data"]["window"]["start"] = START.isoformat()
with pytest.raises(DomainError) as failure:
await publish(task, repository, service, evidence_id, "524529", value)
assert failure.value.code == code
assert not repository.scada_presentations
@pytest.mark.anyio
async def test_previous_run_charts_never_satisfy_a_new_run():
task, repository, service, evidence_id = await investigation()
original = repository.run["run_id"]
repository.scada_presentations[(task["task_id"], original)] = coverage(DEVICES)
task["execution_generation"] += 1
repository.run = _active_run(task, END)
repository.runs[repository.run["run_id"]] = repository.run
with pytest.raises(DomainError) as failure:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=final_report(evidence_id),
))
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
assert original != repository.run["run_id"]
assert (task["task_id"], original) in repository.scada_presentations
@pytest.mark.anyio
async def test_one_device_cannot_inflate_comparison_object_count_with_a_new_chart_id():
task, repository, service, evidence_id = await investigation()
await publish(task, repository, service, evidence_id, "524529", chart("comparison", ["524529"]))
with pytest.raises(DomainError) as failure:
await publish(task, repository, service, evidence_id, "524529-duplicate", chart("comparison", ["524529"]))
assert failure.value.code == "SCADA_CHART_DUPLICATE_DEVICE"
assert failure.value.refs == ["524529"]
await publish(task, repository, service, evidence_id, "524529", chart("comparison", ["524529"]))
assert len(repository.scada_presentations[(task["task_id"], repository.run["run_id"])]["charts"]) == 1
@pytest.mark.anyio
async def test_server_frozen_boundary_proves_unavailable_history_without_fabricating_a_query():
task, repository, service, evidence_id = await investigation()
replay_id = str(uuid4())
task["source"]["replay_context"] = {"replay_id": replay_id, "knowledge_cutoff": END.isoformat(), "batch_sequence": 3}
snapshot = repository.evidence[evidence_id]["data_snapshot"]["data"]
snapshot["window"]["start"] = START.isoformat()
snapshot["data"]["coverage"].update(replay_id=replay_id, batch_sequence=3, frozen_start=START.isoformat())
value = chart("comparison", ["524529"])
for series in value["series"][1:]:
series["data"] = [None] * 13
series["sample_times"] = [None] * 13
await publish(task, repository, service, evidence_id, "524529", value)
gaps = repository.events[-1]["metadata"]["command"]["chart"]["scada"]["gaps"]
assert len(gaps) == 7
assert {gap["reason"] for gap in gaps} == {"outside_frozen_window"}
@pytest.mark.anyio
async def test_interim_monitoring_table_targets_cannot_be_removed_from_a_later_final_report():
task, repository, service, evidence_id = await investigation()
incomplete = coverage(DEVICES[:3])
incomplete["targets"] = DEVICES[:3]
incomplete["charts"]["overview"]["device_ids"] = DEVICES[:3]
repository.scada_presentations[(task["task_id"], repository.run["run_id"])] = incomplete
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**final_report(evidence_id), "stage": "interim"},
))
with pytest.raises(DomainError) as failure:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**final_report(evidence_id), "scada_device_ids": DEVICES[:3]},
))
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
assert failure.value.refs == ["524529"]
@pytest.mark.anyio
async def test_narrative_diagram_references_do_not_declare_business_display_targets():
task, repository, service, evidence_id = await investigation()
selected = DEVICES[:3]
document = coverage(selected)
document["targets"] = selected
document["charts"]["overview"]["device_ids"] = selected
repository.scada_presentations[(task["task_id"], repository.run["run_id"])] = document
report = {**final_report(evidence_id), "scada_device_ids": selected, "diagram": {
"title": "待选参考测点", "mermaid": "flowchart LR\nreference[参考测点]", "node_refs": [{
"node_id": "reference", "label": "参考测点", "map_targets": [{"source_id": "scada", "feature_id": "524529"}],
}],
}}
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report,
))
assert result.report_revision == 1
@pytest.mark.anyio
async def test_unaligned_cutoff_keeps_actual_quarter_hour_samples():
task, repository, service, evidence_id = await investigation()
task["trigger"]["occurred_at"] = (END + timedelta(minutes=8)).isoformat()
value = chart("comparison", ["524529"])
value["scada"].update(window_start=(START + timedelta(minutes=8)).isoformat(),
window_end=(END + timedelta(minutes=8)).isoformat())
value["x_data"] = value["x_data"][1:]
for item in value["series"]:
item["data"] = item["data"][1:]
item["sample_times"] = item["sample_times"][1:]
await publish(task, repository, service, evidence_id, "unaligned", value)
@pytest.mark.anyio
@pytest.mark.parametrize("frequency", ["15min", "15分钟"])
async def test_wholly_missing_devices_accept_supported_cadence_formats(frequency):
task, repository, service, evidence_id = await investigation()
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
data["readings"] = []
for device in data["devices"]:
device["transmission_frequency"] = frequency
value = chart("comparison", ["524529"])
for item in value["series"]:
item["data"] = [None] * 13
item["sample_times"] = [None] * 13
await publish(task, repository, service, evidence_id, "missing", value)
@pytest.mark.anyio
async def test_one_minute_chunks_require_union_of_verified_slots():
task, repository, service, evidence_id = await investigation()
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
data["devices"] = [{**data["devices"][0], "transmission_frequency": "1min"}]
device = DEVICES[0]
data["readings"] = [{"device_id": device, "time": (START + timedelta(minutes=i, days=-day)).isoformat(),
"monitored_value": float(i + 1)}
for day in range(8) for i in (range(181) if day == 0 else range(178, 181))]
def chunk(role, first, stop, missing_current=False):
value = chart(role, [device])
value["x_data"] = [(START + timedelta(minutes=i, hours=8)).strftime("%H:%M") for i in range(first, stop)]
for item in value["series"]:
day = item["day_offset"]
present = lambda i: (not day or i >= 178) and not (missing_current and day == 0)
item["data"] = [float(i + 1) if present(i) else None for i in range(first, stop)]
item["sample_times"] = [(START + timedelta(minutes=i, days=-day)).isoformat() if present(i) else None
for i in range(first, stop)]
return value
report = {**final_report(evidence_id), "scada_device_ids": [device]}
for role in ("current", "comparison"):
await publish(task, repository, service, evidence_id, role + "-first", chunk(role, 0, 100))
with pytest.raises(DomainError) as failure:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report))
assert failure.value.code == "SCADA_PRESENTATION_INCOMPLETE"
with pytest.raises(DomainError) as duplicate:
await publish(task, repository, service, evidence_id, "overlap", chunk("comparison", 99, 181))
assert duplicate.value.code == "SCADA_CHART_DUPLICATE_DEVICE"
for role in ("current", "comparison"):
await publish(task, repository, service, evidence_id, role + "-last", chunk(role, 100, 181))
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report=report))
# Missing current data still permits the last chunk's historical dates across midnight.
data["readings"] = [row for row in data["readings"] if datetime.fromisoformat(row["time"]) < START]
await publish(task, repository, service, evidence_id, "comparison-last", chunk("comparison", 100, 181, True))
@pytest.mark.parametrize("start,end,labels,historical", [
("2026-07-16T05:08:00+08:00", "2026-07-16T08:08:00+08:00", ["07:45", "08:00"],
["2026-07-15T07:45:00+08:00", "2026-07-15T08:00:00+08:00"]),
("2026-07-15T22:08:00+08:00", "2026-07-16T01:08:00+08:00", ["00:45", "01:00"],
["2026-07-15T00:45:00+08:00", "2026-07-15T01:00:00+08:00"]),
])
def test_missing_current_chunk_uses_its_first_slot_date(start, end, labels, historical):
from app.services.time_comparison_chart import validate_time_comparison_chart
validate_time_comparison_chart({"chart_type": "time_comparison", "x_data": labels,
"scada": {"role": "comparison", "window_start": start, "window_end": end},
"series": [{"type": "line", "data": [None, None], "sample_times": [None, None]},
{"type": "scatter", "data": [1., 2.], "sample_times": historical}]},
allow_missing_current=True)
@pytest.mark.anyio
async def test_missing_current_preserves_observed_historical_sampling_phase():
task, repository, service, evidence_id = await investigation()
data = repository.evidence[evidence_id]["data_snapshot"]["data"]["data"]
data["readings"] = [{**row, "time": (datetime.fromisoformat(row["time"]) + timedelta(minutes=5)).isoformat()}
for row in data["readings"] if datetime.fromisoformat(row["time"]) < START]
value = chart("comparison", ["524529"])
value["x_data"] = [(START + timedelta(minutes=15 * i + 5, hours=8)).strftime("%H:%M") for i in range(12)]
for item in value["series"]:
day = item["day_offset"]
item["data"] = [float(i + 1) if day and i >= 9 else None for i in range(12)]
item["sample_times"] = [(START + timedelta(minutes=15 * i + 5, days=-day)).isoformat()
if day and i >= 9 else None for i in range(12)]
# The final three corresponding slots are now 23:20,23:35,23:50 UTC.
for day in range(1, 8):
data["readings"].append({"device_id": "524529", "time": (START + timedelta(minutes=140, days=-day)).isoformat(),
"monitored_value": 10.})
await publish(task, repository, service, evidence_id, "historical-phase", value)
@pytest.mark.anyio
@pytest.mark.parametrize("point_only", [False, True])
async def test_six_hour_devices_with_no_scheduled_slots_require_full_absence_proof(point_only):
task, repository, service, evidence_id = await investigation()
end = END + timedelta(hours=2)
start = end - timedelta(hours=3)
task["trigger"]["occurred_at"] = end.isoformat()
snapshot = repository.evidence[evidence_id]["data_snapshot"]["data"]
snapshot["window"] = {"start": (end if point_only else start - timedelta(days=7)).isoformat(), "end": end.isoformat()}
snapshot["data"]["readings"] = []
for device in snapshot["data"]["devices"]:
device["transmission_frequency"] = "6:00:00"
for role in ("current", "comparison"):
value = chart(role, ["524529"])
value["scada"].update(window_start=start.isoformat(), window_end=end.isoformat())
value["x_data"] = ["10:00"]
for item in value["series"]:
item["data"], item["sample_times"] = [None], [None]
if point_only:
with pytest.raises(DomainError) as failure:
await publish(task, repository, service, evidence_id, role, value)
assert failure.value.code == "SCADA_CHART_EVIDENCE_REQUIRED"
else:
await publish(task, repository, service, evidence_id, role, value)
gaps = repository.events[-1]["metadata"]["command"]["chart"]["scada"]["gaps"]
assert all(gap["reason"] == "no_scheduled_samples" for gap in gaps)
if not point_only:
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
request_id=uuid4(), source_session_id="session-1", report={**final_report(evidence_id), "scada_device_ids": ["524529"]}))