mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 15:51:10 +00:00
745ab806f1
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>
201 lines
6.0 KiB
Rust
201 lines
6.0 KiB
Rust
//! 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"
|
|
);
|
|
}
|