110 lines
5.6 KiB
Python
110 lines
5.6 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from app.services.operational_recovery import accept_completion, evaluate_recovery, track_work_order
|
|
from app.services.operational_tasks import OperationalTaskService
|
|
|
|
|
|
INCIDENT = datetime(2026, 7, 16, 0, tzinfo=timezone.utc)
|
|
RETURNED = INCIDENT + timedelta(hours=1)
|
|
|
|
|
|
def observations():
|
|
rows = []
|
|
for sample in (1, 2, 3):
|
|
at = RETURNED + timedelta(minutes=15 * sample)
|
|
for device in ("P1", "F1"):
|
|
for day in range(1, 16):
|
|
rows.append({"time": at - timedelta(days=day), "device_id": device, "monitored_value": 100.})
|
|
rows.append({"time": at, "device_id": device, "monitored_value": 100.})
|
|
return rows
|
|
|
|
|
|
def evaluate(rows):
|
|
return evaluate_recovery(rows, device_ids=["P1", "F1"], returned_at=RETURNED,
|
|
incident_at=INCIDENT, interval_seconds=900)
|
|
|
|
|
|
def test_recovery_uses_post_receipt_samples_and_pre_incident_same_phase_reference():
|
|
result = evaluate(observations())
|
|
assert result["status"] == "passed"
|
|
assert [item["at"] for item in result["samples"]] == [
|
|
(RETURNED + timedelta(minutes=30)).isoformat(), (RETURNED + timedelta(minutes=45)).isoformat()]
|
|
assert all(check["normal_day_count"] == 15 for sample in result["samples"] for check in sample["devices"])
|
|
|
|
|
|
@pytest.mark.parametrize("problem", ["missing_device", "low_pressure", "high_flow", "nan", "bad_quality", "missing_baseline", "gap"])
|
|
def test_incomplete_or_abnormal_data_never_proves_recovery(problem):
|
|
rows = observations()
|
|
current = [row for row in rows if row["time"] > RETURNED]
|
|
for row in current:
|
|
if problem == "missing_device" and row["device_id"] == "P1":
|
|
rows.remove(row)
|
|
elif problem in {"low_pressure", "high_flow", "nan"}:
|
|
row["monitored_value"] = {"low_pressure": 60., "high_flow": 130., "nan": float("nan")}[problem]
|
|
elif problem == "bad_quality":
|
|
row["quality"] = "invalid"
|
|
elif problem == "gap":
|
|
row["time"] += timedelta(minutes=15 * current.index(row))
|
|
if problem == "missing_baseline":
|
|
rows = [row for row in rows if row["time"] >= INCIDENT - timedelta(days=14)]
|
|
assert evaluate(rows)["status"] == "waiting"
|
|
|
|
|
|
def test_samples_at_or_before_return_time_cannot_close_incident():
|
|
rows = [row for row in observations() if row["time"] < INCIDENT]
|
|
rows.extend({"time": RETURNED, "device_id": device, "monitored_value": 100.} for device in ("P1", "F1"))
|
|
assert evaluate(rows)["status"] == "waiting"
|
|
|
|
|
|
def test_faster_sampling_counts_monitoring_periods_and_rejects_later_relapse():
|
|
rows = []
|
|
for minute in range(5, 36, 5):
|
|
at = RETURNED + timedelta(minutes=minute)
|
|
for device in ("P1", "F1"):
|
|
rows.extend({"time": at - timedelta(days=day), "device_id": device, "monitored_value": 100.}
|
|
for day in range(1, 16))
|
|
rows.append({"time": at, "device_id": device, "monitored_value": 100.})
|
|
assert evaluate(rows)["status"] == "passed"
|
|
next(row for row in rows if row["time"] == RETURNED + timedelta(minutes=25) and row["device_id"] == "P1")["monitored_value"] = 50.
|
|
assert evaluate(rows)["status"] == "waiting"
|
|
|
|
|
|
def test_dispatch_waits_for_all_orders_and_accepts_corrected_field_completion_time():
|
|
task = {"workflow": "burst_report"}
|
|
for order_id in ("one", "two"):
|
|
track_work_order(task, {"kind": "create_work_order", "action_id": str(uuid4()), "execution_status": "succeeded",
|
|
"receipt": {"adapter": "peripheral_sample", "receipt_id": order_id}})
|
|
assert OperationalTaskService._investigation_status([], task) == "waiting_human"
|
|
assert not accept_completion(task, {"source_event_id": "peripheral_sample:unrelated"})
|
|
feedback = {"source_event_id": "peripheral_sample:one", "status": "completed", "source_revision": 2,
|
|
"occurred_at": RETURNED.isoformat()}
|
|
assert accept_completion(task, feedback)
|
|
assert task["handling_context"]["phase"] == "waiting_work_order"
|
|
accept_completion(task, {**feedback, "source_event_id": "peripheral_sample:two"})
|
|
assert task["handling_context"]["phase"] == "waiting_scada"
|
|
task["handling_context"]["phase"] = "verification_ready"
|
|
task["handling_context"].update(verification_evidence_id="previous-proof", last_check={"status": "passed"})
|
|
accept_completion(task, {**feedback, "source_revision": 3})
|
|
assert task["handling_context"]["phase"] == "verification_ready"
|
|
assert OperationalTaskService._investigation_status([], task) == "completed"
|
|
corrected = RETURNED + timedelta(hours=1)
|
|
accept_completion(task, {**feedback, "source_revision": 4, "occurred_at": corrected.isoformat()})
|
|
assert task["handling_context"]["returned_at"] == corrected.isoformat()
|
|
assert task["handling_context"]["phase"] == "waiting_scada"
|
|
assert "verification_evidence_id" not in task["handling_context"] and "last_check" not in task["handling_context"]
|
|
|
|
|
|
def test_field_feedback_time_requires_a_real_note_and_explicit_timezone():
|
|
from pydantic import ValidationError
|
|
from samples.peripheral.schemas import UpdateWorkOrder
|
|
body = {"request_id": uuid4(), "expected_revision": 1, "status": "completed",
|
|
"progress_note": "演示抢修完成并恢复供水", "occurred_at": RETURNED.isoformat()}
|
|
assert UpdateWorkOrder.model_validate(body).occurred_at == RETURNED
|
|
with pytest.raises(ValidationError):
|
|
UpdateWorkOrder.model_validate({**body, "progress_note": None})
|
|
with pytest.raises(ValidationError):
|
|
UpdateWorkOrder.model_validate({**body, "occurred_at": "2026-07-16T09:00:00"})
|