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