Files
Oleksandr Bezdieniezhnykh 745ab806f1
ci/woodpecker/push/build-arm Pipeline failed
[AZ-657] [AZ-682] frame_ingest RTSP lifecycle + scan_controller FSM (batch 12)
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>
2026-05-20 08:17:27 +03:00

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