//! 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" ); }