Files
next-tjwater-server/tests/unit/test_operational_recovery.py
T
jiang dc83cf5c88
Generic Container CI/CD / test-build-publish (push) Failing after 47s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 47s
fix(scada): verify first sample after field completion
2026-11-07 19:50:31 +08:00

135 lines
6.7 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_nearest_post_receipt_sample_and_pre_incident_reference():
result = evaluate(observations())
assert result["status"] == "passed"
assert result["required_samples"] == 1
assert [item["at"] for item in result["samples"]] == [(RETURNED + timedelta(minutes=15)).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_nearest_sample_does_not_wait_for_monitoring_interval_or_use_later_data():
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.})
first = evaluate(rows)
assert first["status"] == "passed"
assert first["verified_through"] == (RETURNED + timedelta(minutes=5)).isoformat()
next(row for row in rows if row["time"] == RETURNED + timedelta(minutes=25) and row["device_id"] == "P1")["monitored_value"] = 50.
assert evaluate(rows) == first
@pytest.mark.parametrize("problem", ["abnormal", "invalid", "null", "missing_device"])
def test_nearest_bad_sample_is_not_replaced_by_later_normal_samples(problem):
rows = observations()
nearest = RETURNED + timedelta(minutes=15)
row = next(row for row in rows if row["time"] == nearest and row["device_id"] == "P1")
if problem == "missing_device":
rows.remove(row)
elif problem == "invalid":
row["quality"] = "invalid"
else:
row["monitored_value"] = 50. if problem == "abnormal" else None
result = evaluate(rows)
assert result["status"] == "waiting"
assert result["sample"]["at"] == nearest.isoformat()
def test_off_grid_feedback_uses_first_later_sample_even_without_monitoring_interval():
result = evaluate_recovery(observations(), device_ids=["P1", "F1"],
returned_at=RETURNED + timedelta(minutes=7), incident_at=INCIDENT, interval_seconds=0)
assert result["status"] == "passed"
assert result["verified_through"] == (RETURNED + timedelta(minutes=15)).isoformat()
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"})