"""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"]}))