mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-22 07:01:10 +00:00
[AZ-657] [AZ-682] frame_ingest RTSP lifecycle + scan_controller FSM (batch 12)
ci/woodpecker/push/build-arm Pipeline failed
ci/woodpecker/push/build-arm Pipeline failed
AZ-657 (frame_ingest): RTSP session lifecycle FSM with bounded exponential backoff (1 s → 30 s cap), AI-lock plumb through watch::Sender that stamps every emitted Frame, and SPS/PPS hard-fail via OpenError::UnsupportedProfile. The actual RTSP wire client is abstracted behind an RtspTransport trait so AZ-658 can pin retina/FFmpeg alongside the decoder; the lifecycle FSM itself is production code today. tokio::select! around every transport call so a hung open/read cannot wedge graceful shutdown. 10 unit + 5 integration tests cover happy path, bounded reconnect, stream- drop reopen, hard-fail no-retry, and AI-lock toggle. AZ-682 (scan_controller): typed ScanState (ZoomedOut / ZoomedIn / TargetFollow) with a complete pure transition catalogue, every (state, trigger) → next_state from description.md §1/§4/§5 covered; spec-disallowed combos return TransitionOutcome.accepted = false with RejectReason::UnsupportedTransition (loud, not silent). Frame- rate floor monitor with hysteresis suppresses ZoomedOut → ZoomedIn while sustained FPS < 10 fps per description.md §5/§6. Rolling 100-sample tick-latency window surfaces p99; health goes yellow above the 10 ms budget. 18 unit + 5 integration tests cover the catalogue, fps-floor activate/clear, and tick-latency budget. Cumulative review (batches 10-12): all OPEN findings carried forward without regressions. See _docs/03_implementation/batch_12_cycle1_report.md §6. Notes: pre-existing dead-code error in autopilot::Runtime:: vlm_provider_name (origin batch 4) blocks workspace -D warnings clippy. Recorded in _docs/_process_leftovers/ — not in batch 12 scope. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
//! AZ-682 integration tests — exercise the typed state machine and
|
||||
//! the frame-rate floor monitor end-to-end through the public
|
||||
//! `ScanControllerHandle` surface.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use scan_controller::{RejectReason, ScanController, ScanState, TransitionOutcome, Trigger};
|
||||
|
||||
#[tokio::test]
|
||||
async fn ac1_boot_state_is_zoomed_out() {
|
||||
// Arrange
|
||||
let h = ScanController::new().handle();
|
||||
|
||||
// Assert
|
||||
assert_eq!(h.state().await, ScanState::ZoomedOut);
|
||||
}
|
||||
|
||||
/// AC-2 — transition catalogue is complete; every (from_state,
|
||||
/// trigger) → to_state from the spec is covered. Spec-disallowed
|
||||
/// combinations are rejected with a recorded reason.
|
||||
#[tokio::test]
|
||||
async fn ac2_full_transition_catalogue_round_trip() {
|
||||
// Arrange
|
||||
let h = ScanController::new().handle();
|
||||
let roi = Uuid::new_v4();
|
||||
let target = Uuid::new_v4();
|
||||
|
||||
// Act + Assert — ZoomedOut → ZoomedIn
|
||||
let o = h
|
||||
.submit_trigger(Trigger::PoiSelected { roi, now_ns: 100 })
|
||||
.await;
|
||||
assert!(o.accepted, "PoiSelected must transition");
|
||||
assert!(matches!(
|
||||
h.state().await,
|
||||
ScanState::ZoomedIn { roi: r, hold_started_at_ns: 100 } if r == roi
|
||||
));
|
||||
|
||||
// ZoomedIn → TargetFollow
|
||||
let o = h
|
||||
.submit_trigger(Trigger::TargetConfirmed {
|
||||
target_id: target,
|
||||
now_ns: 200,
|
||||
})
|
||||
.await;
|
||||
assert!(o.accepted);
|
||||
assert!(matches!(
|
||||
h.state().await,
|
||||
ScanState::TargetFollow { target_id: t, started_at_ns: 200 } if t == target
|
||||
));
|
||||
|
||||
// TargetFollow → ZoomedOut via TargetLost
|
||||
let o = h.submit_trigger(Trigger::TargetLost).await;
|
||||
assert!(o.accepted);
|
||||
assert_eq!(h.state().await, ScanState::ZoomedOut);
|
||||
|
||||
// ZoomedOut → ZoomedIn again → ZoomedOut via RoiRejected
|
||||
let _ = h
|
||||
.submit_trigger(Trigger::PoiSelected {
|
||||
roi: Uuid::new_v4(),
|
||||
now_ns: 300,
|
||||
})
|
||||
.await;
|
||||
let o = h.submit_trigger(Trigger::RoiRejected).await;
|
||||
assert!(o.accepted);
|
||||
assert_eq!(h.state().await, ScanState::ZoomedOut);
|
||||
|
||||
// ZoomedOut → ZoomedIn → ZoomedOut via RoiHoldTimeout
|
||||
let _ = h
|
||||
.submit_trigger(Trigger::PoiSelected {
|
||||
roi: Uuid::new_v4(),
|
||||
now_ns: 400,
|
||||
})
|
||||
.await;
|
||||
let o = h.submit_trigger(Trigger::RoiHoldTimeout).await;
|
||||
assert!(o.accepted);
|
||||
assert_eq!(h.state().await, ScanState::ZoomedOut);
|
||||
|
||||
// TargetFollow → ZoomedOut via OperatorReleaseFollow
|
||||
let _ = h
|
||||
.submit_trigger(Trigger::PoiSelected {
|
||||
roi: Uuid::new_v4(),
|
||||
now_ns: 500,
|
||||
})
|
||||
.await;
|
||||
let _ = h
|
||||
.submit_trigger(Trigger::TargetConfirmed {
|
||||
target_id: Uuid::new_v4(),
|
||||
now_ns: 600,
|
||||
})
|
||||
.await;
|
||||
let o = h.submit_trigger(Trigger::OperatorReleaseFollow).await;
|
||||
assert!(o.accepted);
|
||||
assert_eq!(h.state().await, ScanState::ZoomedOut);
|
||||
|
||||
// OperatorAbort from TargetFollow
|
||||
let _ = h
|
||||
.submit_trigger(Trigger::PoiSelected {
|
||||
roi: Uuid::new_v4(),
|
||||
now_ns: 700,
|
||||
})
|
||||
.await;
|
||||
let _ = h
|
||||
.submit_trigger(Trigger::TargetConfirmed {
|
||||
target_id: Uuid::new_v4(),
|
||||
now_ns: 800,
|
||||
})
|
||||
.await;
|
||||
let o = h.submit_trigger(Trigger::OperatorAbort).await;
|
||||
assert!(o.accepted);
|
||||
assert_eq!(h.state().await, ScanState::ZoomedOut);
|
||||
}
|
||||
|
||||
/// AC-3 — spec-disallowed transitions are rejected with the
|
||||
/// `UnsupportedTransition` reason (not silently no-ops).
|
||||
#[tokio::test]
|
||||
async fn ac3_unsupported_transitions_are_rejected() {
|
||||
// Arrange
|
||||
let h = ScanController::new().handle();
|
||||
|
||||
// Act — TargetConfirmed makes no sense while ZoomedOut.
|
||||
let o = h
|
||||
.submit_trigger(Trigger::TargetConfirmed {
|
||||
target_id: Uuid::new_v4(),
|
||||
now_ns: 0,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Assert
|
||||
assert!(!o.accepted);
|
||||
assert_eq!(o.reject_reason, Some(RejectReason::UnsupportedTransition));
|
||||
assert_eq!(o.next, ScanState::ZoomedOut);
|
||||
}
|
||||
|
||||
/// AC-4 — frame-rate floor suppresses zoom-in; once cleared, zoom-in
|
||||
/// transitions resume.
|
||||
#[tokio::test]
|
||||
async fn ac4_frame_rate_floor_suppresses_then_clears() {
|
||||
// Arrange — feed 5 fps until the guard activates.
|
||||
let h = ScanController::new().handle();
|
||||
for i in 0..6u64 {
|
||||
h.observe_frame_at(i * 200_000_000).await;
|
||||
}
|
||||
assert!(h.fps_floor_active().await);
|
||||
|
||||
// Act — PoiSelected must be suppressed.
|
||||
let o: TransitionOutcome = h
|
||||
.submit_trigger(Trigger::PoiSelected {
|
||||
roi: Uuid::new_v4(),
|
||||
now_ns: 1_500_000_000,
|
||||
})
|
||||
.await;
|
||||
assert!(!o.accepted);
|
||||
assert_eq!(o.reject_reason, Some(RejectReason::FpsFloor));
|
||||
|
||||
// Recovery — feed 30 fps for 2 seconds; floor must clear.
|
||||
let start = 2_000_000_000u64;
|
||||
let step = (1e9_f64 / 30.0) as u64;
|
||||
for i in 0..60u64 {
|
||||
h.observe_frame_at(start + i * step).await;
|
||||
}
|
||||
assert!(!h.fps_floor_active().await);
|
||||
|
||||
// The same PoiSelected MUST now succeed.
|
||||
let o = h
|
||||
.submit_trigger(Trigger::PoiSelected {
|
||||
roi: Uuid::new_v4(),
|
||||
now_ns: 3_000_000_000,
|
||||
})
|
||||
.await;
|
||||
assert!(o.accepted);
|
||||
assert!(matches!(h.state().await, ScanState::ZoomedIn { .. }));
|
||||
}
|
||||
|
||||
/// AC-5 — tick latency stays well under the 10 ms p99 budget under
|
||||
/// a steady-state trigger load. This bench is a smoke test, not a
|
||||
/// rigorous benchmark — it exists to catch a regression that
|
||||
/// silently blows past the budget.
|
||||
#[tokio::test]
|
||||
async fn ac5_tick_latency_p99_under_budget() {
|
||||
// Arrange
|
||||
let h = ScanController::new().handle();
|
||||
|
||||
// Act — run 200 triggers through the FSM.
|
||||
for i in 0..200u64 {
|
||||
let _ = h
|
||||
.submit_trigger(Trigger::PoiSelected {
|
||||
roi: Uuid::new_v4(),
|
||||
now_ns: i * 1_000_000,
|
||||
})
|
||||
.await;
|
||||
let _ = h.submit_trigger(Trigger::OperatorAbort).await;
|
||||
}
|
||||
|
||||
// Assert
|
||||
let p99 = h.tick_latency_p99_us().await;
|
||||
assert!(
|
||||
p99 < 10_000,
|
||||
"tick latency p99 {p99} us exceeds 10 ms budget"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user