mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-22 01:01:09 +00:00
[AZ-652] mission_executor safety + resume + middle-waypoint (batch 9)
Geofence (INCLUSION+EXCLUSION, ≤500 ms detect→RTL), battery thresholds (RTL@25%/land@15% + signed override), middle-waypoint re-upload (CLEAR_ALL→upload→SET_CURRENT(0)), and post-flight mapobjects push trigger. Adds production MAVLink command issuers for both geofence and battery failsafe families. Implements 6 ACs with 12 integration tests + module unit tests; full workspace test suite green. See batch_09_cycle1_report.md for AC coverage and known limitations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,690 @@
|
||||
//! AZ-652 acceptance criteria — geofence + battery thresholds +
|
||||
//! middle-waypoint re-upload + post-flight push trigger.
|
||||
//!
|
||||
//! Tests are scoped to the **monitor + handle** boundary. The driver
|
||||
//! wrappers ([`GeofenceDriver`], [`BatteryDriver`]) are exercised via
|
||||
//! the same code path that production composition uses (the spawn /
|
||||
//! tick loop). Per-AC tests assert the observable contract from the
|
||||
//! task spec; supplementary tests cover non-AC paths (override,
|
||||
//! target-follow release).
|
||||
|
||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use mission_executor::{
|
||||
BatteryAction, BatteryCommandIssuer, BatteryConfig, BatteryDriver, BatteryEvent,
|
||||
BatteryMonitor, BatteryOverride, DriverError, FailsafeKind, GeofenceCommandIssuer,
|
||||
GeofenceDriver, GeofenceMonitor, MapObjectsDiffSource, MapObjectsPusher, MiddleWaypointHint,
|
||||
MissionDriver, MissionRePlanner, PostFlightPusher,
|
||||
};
|
||||
|
||||
use mission_client::{MapObjectsDiff, PerEndpointStatus, PushReport, SyncState};
|
||||
use shared::error::AutopilotError;
|
||||
use shared::models::mission::{
|
||||
Coordinate, Geofence, GeofenceKind, MavCommand, MavFrame, MissionWaypoint,
|
||||
};
|
||||
use shared::models::telemetry::{UavPosition, UavSysStatus};
|
||||
|
||||
// ============================================================================
|
||||
// Spies + fakes
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Default)]
|
||||
struct RtlSpy {
|
||||
rtl_count: AtomicU32,
|
||||
land_now_count: AtomicU32,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GeofenceCommandIssuer for RtlSpy {
|
||||
async fn issue_rtl(&self) -> Result<(), AutopilotError> {
|
||||
self.rtl_count.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BatteryCommandIssuer for RtlSpy {
|
||||
async fn issue_rtl(&self) -> Result<(), AutopilotError> {
|
||||
self.rtl_count.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
async fn issue_land_now(&self) -> Result<(), AutopilotError> {
|
||||
self.land_now_count.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct UploadSpy {
|
||||
calls: StdMutex<Vec<Vec<MissionWaypoint>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MissionDriver for UploadSpy {
|
||||
async fn arm(&self) -> Result<(), DriverError> {
|
||||
unreachable!("arm should not be called in this test")
|
||||
}
|
||||
async fn takeoff(&self, _altitude_m: f32) -> Result<(), DriverError> {
|
||||
unreachable!("takeoff should not be called in this test")
|
||||
}
|
||||
async fn upload_mission(&self, items: &[MissionWaypoint]) -> Result<(), DriverError> {
|
||||
self.calls.lock().unwrap().push(items.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn set_auto_mode(&self) -> Result<(), DriverError> {
|
||||
unreachable!("set_auto_mode should not be called in this test")
|
||||
}
|
||||
async fn post_flight_sync(&self) -> Result<(), DriverError> {
|
||||
unreachable!("post_flight_sync should not be called in this test")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SpyMapObjectsPusher {
|
||||
calls: StdMutex<Vec<(String, MapObjectsDiff)>>,
|
||||
template: StdMutex<Option<PushReport>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MapObjectsPusher for SpyMapObjectsPusher {
|
||||
async fn push(&self, mission_id: &str, diff: MapObjectsDiff) -> PushReport {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((mission_id.to_owned(), diff.clone()));
|
||||
self.template
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or_else(|| PushReport {
|
||||
mission_id: mission_id.to_owned(),
|
||||
observations: PerEndpointStatus::Success,
|
||||
ignored: PerEndpointStatus::Success,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CountingDiffSource {
|
||||
drain_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MapObjectsDiffSource for CountingDiffSource {
|
||||
async fn drain_diff(&self) -> MapObjectsDiff {
|
||||
self.drain_calls.fetch_add(1, Ordering::Relaxed);
|
||||
MapObjectsDiff::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Geofence helpers
|
||||
// ============================================================================
|
||||
|
||||
fn coord(lat: f64, lon: f64) -> Coordinate {
|
||||
Coordinate {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
altitude_m: 50.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn pos_at(lat: f64, lon: f64) -> UavPosition {
|
||||
UavPosition {
|
||||
lat_e7: (lat * 1.0e7) as i32,
|
||||
lon_e7: (lon * 1.0e7) as i32,
|
||||
alt_m: 50.0,
|
||||
relative_alt_m: 50.0,
|
||||
vx_mps: 0.0,
|
||||
vy_mps: 0.0,
|
||||
vz_mps: 0.0,
|
||||
heading_deg: 0.0,
|
||||
ts_boot_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn inclusion_square() -> Geofence {
|
||||
Geofence {
|
||||
kind: GeofenceKind::Inclusion,
|
||||
vertices: vec![
|
||||
coord(50.0, 30.0),
|
||||
coord(50.0, 31.0),
|
||||
coord(51.0, 31.0),
|
||||
coord(51.0, 30.0),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn exclusion_square() -> Geofence {
|
||||
Geofence {
|
||||
kind: GeofenceKind::Exclusion,
|
||||
vertices: vec![
|
||||
coord(50.4, 30.4),
|
||||
coord(50.4, 30.6),
|
||||
coord(50.6, 30.6),
|
||||
coord(50.6, 30.4),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_until<F: FnMut() -> bool>(deadline: Duration, mut predicate: F, label: &str) {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if predicate() {
|
||||
return;
|
||||
}
|
||||
if start.elapsed() > deadline {
|
||||
panic!("timed out waiting for {label}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AC-1 — INCLUSION exit triggers RTL within ≤500 ms
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ac1_inclusion_geofence_exit_triggers_rtl() {
|
||||
// Arrange — UAV starts inside INCLUSION; driver tick at 25 ms so
|
||||
// total detect-to-RTL stays well under 500 ms.
|
||||
let monitor = GeofenceMonitor::new(vec![inclusion_square()]);
|
||||
let rtl_spy = Arc::new(RtlSpy::default());
|
||||
let executor_only = mission_executor::MissionExecutor::new(
|
||||
mission_executor::MissionExecutorConfig::multirotor(10.0),
|
||||
);
|
||||
let (_tel_tx, tel_rx) = watch::channel(mission_executor::Telemetry::default());
|
||||
let upload_spy = Arc::new(UploadSpy::default());
|
||||
let (handle, fsm_join) = executor_only.run(upload_spy.clone(), vec![], tel_rx);
|
||||
|
||||
let (pos_tx, pos_rx) = watch::channel::<Option<UavPosition>>(Some(pos_at(50.5, 30.5)));
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let driver = GeofenceDriver::new(monitor, handle.clone(), rtl_spy.clone(), pos_rx)
|
||||
.with_tick_interval(Duration::from_millis(25));
|
||||
let (gh, driver_join) = driver.spawn(shutdown_rx);
|
||||
|
||||
// Act — fly outside the polygon.
|
||||
pos_tx.send(Some(pos_at(52.0, 30.5))).unwrap();
|
||||
let t_exit = std::time::Instant::now();
|
||||
|
||||
// Assert — RTL is issued within ≤500 ms and event is observable.
|
||||
wait_until(
|
||||
Duration::from_millis(500),
|
||||
|| rtl_spy.rtl_count.load(Ordering::Relaxed) >= 1,
|
||||
"RTL issued",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
t_exit.elapsed() <= Duration::from_millis(500),
|
||||
"AC-1 ≤500 ms"
|
||||
);
|
||||
assert!(gh.last_verdict().is_violation());
|
||||
|
||||
// Cleanup
|
||||
let _ = shutdown_tx.send(true);
|
||||
driver_join.await.ok();
|
||||
fsm_join.abort();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AC-2 — EXCLUSION entry triggers RTL within ≤500 ms (symmetric)
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ac2_exclusion_geofence_entry_triggers_rtl() {
|
||||
// Arrange — UAV starts inside INCLUSION + outside EXCLUSION.
|
||||
let monitor = GeofenceMonitor::new(vec![inclusion_square(), exclusion_square()]);
|
||||
let rtl_spy = Arc::new(RtlSpy::default());
|
||||
let executor_only = mission_executor::MissionExecutor::new(
|
||||
mission_executor::MissionExecutorConfig::multirotor(10.0),
|
||||
);
|
||||
let (_tel_tx, tel_rx) = watch::channel(mission_executor::Telemetry::default());
|
||||
let upload_spy = Arc::new(UploadSpy::default());
|
||||
let (handle, fsm_join) = executor_only.run(upload_spy.clone(), vec![], tel_rx);
|
||||
|
||||
let (pos_tx, pos_rx) = watch::channel::<Option<UavPosition>>(Some(pos_at(50.2, 30.2)));
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let driver = GeofenceDriver::new(monitor, handle.clone(), rtl_spy.clone(), pos_rx)
|
||||
.with_tick_interval(Duration::from_millis(25));
|
||||
let (_gh, driver_join) = driver.spawn(shutdown_rx);
|
||||
|
||||
// Act — fly into EXCLUSION polygon.
|
||||
pos_tx.send(Some(pos_at(50.5, 30.5))).unwrap();
|
||||
let t_entry = std::time::Instant::now();
|
||||
|
||||
// Assert — RTL issued within ≤500 ms.
|
||||
wait_until(
|
||||
Duration::from_millis(500),
|
||||
|| rtl_spy.rtl_count.load(Ordering::Relaxed) >= 1,
|
||||
"RTL issued on EXCLUSION entry",
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
t_entry.elapsed() <= Duration::from_millis(500),
|
||||
"AC-2 ≤500 ms"
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
let _ = shutdown_tx.send(true);
|
||||
driver_join.await.ok();
|
||||
fsm_join.abort();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AC-3 — battery thresholds (RTL @ 24 %, land-now @ 14 %)
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ac3a_battery_rtl_at_threshold() {
|
||||
// Arrange
|
||||
let mut monitor = BatteryMonitor::new(BatteryConfig::default());
|
||||
let sys = UavSysStatus {
|
||||
voltage_battery_mv: 12_000,
|
||||
current_battery_ca: 100,
|
||||
battery_remaining: 24,
|
||||
onboard_sensors_health: 0,
|
||||
errors_comm: 0,
|
||||
};
|
||||
|
||||
// Act
|
||||
let action = monitor.tick(&sys, Instant::now());
|
||||
|
||||
// Assert
|
||||
assert_eq!(action, BatteryAction::IssueRtl);
|
||||
assert_eq!(action.failsafe_kind(), Some(FailsafeKind::BatteryRtl));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ac3b_battery_land_now_at_hard_floor_and_flips_health_red() {
|
||||
// Arrange — wire BatteryDriver into a real MissionExecutorHandle
|
||||
// so we can observe `health()` flipping to red on the hard-floor
|
||||
// failsafe.
|
||||
let cmd_spy = Arc::new(RtlSpy::default());
|
||||
let executor_only = mission_executor::MissionExecutor::new(
|
||||
mission_executor::MissionExecutorConfig::multirotor(10.0),
|
||||
);
|
||||
let (_tel_tx, tel_rx) = watch::channel(mission_executor::Telemetry::default());
|
||||
let upload_spy = Arc::new(UploadSpy::default());
|
||||
let (handle, fsm_join) = executor_only.run(upload_spy.clone(), vec![], tel_rx);
|
||||
|
||||
let (sys_tx, sys_rx) = watch::channel::<Option<UavSysStatus>>(None);
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let monitor = BatteryMonitor::new(BatteryConfig::default());
|
||||
let driver = BatteryDriver::new(monitor, handle.clone(), cmd_spy.clone(), sys_rx)
|
||||
.with_tick_interval(Duration::from_millis(20));
|
||||
let (bh, driver_join) = driver.spawn(shutdown_rx);
|
||||
let mut events = bh.subscribe();
|
||||
|
||||
// Act — battery at 10 % triggers land-now and the hard-floor
|
||||
// latch on the executor.
|
||||
sys_tx
|
||||
.send(Some(UavSysStatus {
|
||||
voltage_battery_mv: 11_400,
|
||||
current_battery_ca: 100,
|
||||
battery_remaining: 10,
|
||||
onboard_sensors_health: 0,
|
||||
errors_comm: 0,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
wait_until(
|
||||
Duration::from_millis(500),
|
||||
|| cmd_spy.land_now_count.load(Ordering::Relaxed) >= 1,
|
||||
"land-now command issued",
|
||||
)
|
||||
.await;
|
||||
let evt = events.recv().await.expect("event arrives");
|
||||
|
||||
// Assert — land-now event observable; executor health goes red.
|
||||
assert!(matches!(evt, BatteryEvent::LandNowIssued));
|
||||
assert!(handle.hard_floor_active());
|
||||
let h = handle.health().await;
|
||||
assert_eq!(h.level, shared::health::HealthLevel::Red);
|
||||
|
||||
// Cleanup
|
||||
let _ = shutdown_tx.send(true);
|
||||
driver_join.await.ok();
|
||||
fsm_join.abort();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AC-4 — signed operator override suppresses RTL until `until_ts`
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ac4_signed_override_suppresses_battery_rtl() {
|
||||
// Arrange
|
||||
let mut monitor = BatteryMonitor::new(BatteryConfig::default());
|
||||
let now = Instant::now();
|
||||
monitor.apply_override(BatteryOverride {
|
||||
until: now + Duration::from_secs(60),
|
||||
operator_id: "op-7".into(),
|
||||
rationale: "ferry to safe landing zone".into(),
|
||||
});
|
||||
let sys = UavSysStatus {
|
||||
voltage_battery_mv: 11_800,
|
||||
current_battery_ca: 100,
|
||||
battery_remaining: 22,
|
||||
onboard_sensors_health: 0,
|
||||
errors_comm: 0,
|
||||
};
|
||||
|
||||
// Act — at RTL threshold WITH active override.
|
||||
let suppressed = monitor.tick(&sys, now);
|
||||
|
||||
// Assert
|
||||
assert_eq!(suppressed, BatteryAction::SuppressedByOverride);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AC-5 — middle-waypoint re-upload sequence completes in ≤2 s
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ac5_middle_waypoint_reupload_sequence() {
|
||||
// Arrange
|
||||
let original: Vec<MissionWaypoint> =
|
||||
vec![wp(0, 50.0, 30.0), wp(1, 50.1, 30.1), wp(2, 50.2, 30.2)];
|
||||
let upload_spy = Arc::new(UploadSpy::default());
|
||||
let planner = MissionRePlanner::new(upload_spy.clone());
|
||||
let hint = MiddleWaypointHint {
|
||||
at: Coordinate {
|
||||
latitude: 50.05,
|
||||
longitude: 30.05,
|
||||
altitude_m: 60.0,
|
||||
},
|
||||
insert_after_seq: 0,
|
||||
label: Some("poi-confirmed".into()),
|
||||
};
|
||||
|
||||
// Act
|
||||
let start = std::time::Instant::now();
|
||||
let patched = planner
|
||||
.on_middle_waypoint(hint.clone(), &original)
|
||||
.await
|
||||
.expect("re-upload ok");
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// Assert — upload_mission was called exactly once with the
|
||||
// patched mission, which is the canonical
|
||||
// CLEAR_ALL→upload→SET_CURRENT(0) primitive per the driver
|
||||
// contract. Wall-clock end-to-end is well under 2 s (typically
|
||||
// <1 ms in this in-process test).
|
||||
let calls = upload_spy.calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "exactly one upload_mission call");
|
||||
assert_eq!(calls[0], patched, "uploaded mission matches patched");
|
||||
assert_eq!(patched.len(), original.len() + 1);
|
||||
let middle = patched
|
||||
.iter()
|
||||
.find(|wp| wp.lat_deg_e7 == (50.05 * 1.0e7) as i32)
|
||||
.expect("middle waypoint present");
|
||||
assert_eq!(middle.lon_deg_e7, (30.05 * 1.0e7) as i32);
|
||||
assert!(elapsed <= Duration::from_secs(2), "AC-5 ≤2 s");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AC-6 — post-flight push trigger fires exactly once
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ac6_post_flight_push_triggered_once_executor_reaches_done() {
|
||||
// Arrange
|
||||
let spy = Arc::new(SpyMapObjectsPusher::default());
|
||||
let diff_source = Arc::new(CountingDiffSource::default());
|
||||
let pusher = PostFlightPusher::new(spy.clone(), diff_source.clone());
|
||||
|
||||
// Act
|
||||
let report = pusher.push("MISSION-XYZ").await;
|
||||
|
||||
// Assert — pusher called exactly once; FSM-side guarantee is that
|
||||
// the driver impl always returns Ok regardless of `report`
|
||||
// (see `post_flight::PostFlightPusher::push` doc) so the FSM
|
||||
// reaches Done even on Degraded. We re-assert that here so a
|
||||
// regression in the pusher's return contract is caught.
|
||||
assert_eq!(spy.calls.lock().unwrap().len(), 1, "exactly one push");
|
||||
assert_eq!(spy.calls.lock().unwrap()[0].0, "MISSION-XYZ");
|
||||
assert_eq!(diff_source.drain_calls.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(pusher.push_count(), 1);
|
||||
// Default template is Synced — but the contract holds for
|
||||
// Degraded too (covered in post_flight::tests).
|
||||
assert_eq!(report.sync_state(), SyncState::Synced);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AC-6 supplement — Degraded push report still reports back cleanly
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ac6_degraded_push_does_not_block_caller() {
|
||||
// Arrange
|
||||
let spy = Arc::new(SpyMapObjectsPusher::default());
|
||||
*spy.template.lock().unwrap() = Some(PushReport {
|
||||
mission_id: "M2".into(),
|
||||
observations: PerEndpointStatus::Success,
|
||||
ignored: PerEndpointStatus::Permanent {
|
||||
reason: "403 forbidden".into(),
|
||||
},
|
||||
});
|
||||
let diff_source = Arc::new(CountingDiffSource::default());
|
||||
let pusher = PostFlightPusher::new(spy.clone(), diff_source.clone());
|
||||
|
||||
// Act
|
||||
let report = tokio::time::timeout(Duration::from_secs(2), pusher.push("M2"))
|
||||
.await
|
||||
.expect("push returns within budget");
|
||||
|
||||
// Assert — degraded outcome is surfaced; caller is not blocked.
|
||||
assert_eq!(report.sync_state(), SyncState::Degraded);
|
||||
assert_eq!(pusher.push_count(), 1);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Supplementary — failsafe_trigger transitions FSM correctly
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn battery_rtl_failsafe_transitions_flymission_to_land() {
|
||||
// Arrange
|
||||
let executor_only = mission_executor::MissionExecutor::new(
|
||||
mission_executor::MissionExecutorConfig::multirotor(10.0),
|
||||
);
|
||||
let (_tel_tx, tel_rx) = watch::channel(mission_executor::Telemetry::default());
|
||||
let upload_spy = Arc::new(UploadSpy::default());
|
||||
let (handle, fsm_join) = executor_only.run(upload_spy.clone(), vec![], tel_rx);
|
||||
|
||||
// Force the FSM into FlyMission so failsafe_trigger can act on it.
|
||||
force_state(&handle, mission_executor::MissionState::FlyMission).await;
|
||||
|
||||
// Act
|
||||
handle
|
||||
.failsafe_trigger(FailsafeKind::BatteryRtl)
|
||||
.await
|
||||
.expect("ok");
|
||||
|
||||
// Assert
|
||||
assert_eq!(handle.state().await, mission_executor::MissionState::Land);
|
||||
assert!(
|
||||
!handle.hard_floor_active(),
|
||||
"RTL alone should not latch hard floor"
|
||||
);
|
||||
|
||||
fsm_join.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn battery_hard_floor_failsafe_latches_health_red() {
|
||||
// Arrange
|
||||
let executor_only = mission_executor::MissionExecutor::new(
|
||||
mission_executor::MissionExecutorConfig::multirotor(10.0),
|
||||
);
|
||||
let (_tel_tx, tel_rx) = watch::channel(mission_executor::Telemetry::default());
|
||||
let upload_spy = Arc::new(UploadSpy::default());
|
||||
let (handle, fsm_join) = executor_only.run(upload_spy.clone(), vec![], tel_rx);
|
||||
force_state(&handle, mission_executor::MissionState::FlyMission).await;
|
||||
|
||||
// Act
|
||||
handle
|
||||
.failsafe_trigger(FailsafeKind::BatteryHardFloor)
|
||||
.await
|
||||
.expect("ok");
|
||||
|
||||
// Assert
|
||||
assert_eq!(handle.state().await, mission_executor::MissionState::Land);
|
||||
assert!(handle.hard_floor_active());
|
||||
let h = handle.health().await;
|
||||
assert_eq!(h.level, shared::health::HealthLevel::Red);
|
||||
|
||||
// After operator-acknowledged clear, health falls back to yellow.
|
||||
handle.clear_hard_floor();
|
||||
assert_eq!(
|
||||
handle.health().await.level,
|
||||
shared::health::HealthLevel::Yellow
|
||||
);
|
||||
|
||||
fsm_join.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn battery_override_can_be_applied_via_handle_apply_override_channel() {
|
||||
// Arrange
|
||||
let cmd_spy = Arc::new(RtlSpy::default());
|
||||
let executor_only = mission_executor::MissionExecutor::new(
|
||||
mission_executor::MissionExecutorConfig::multirotor(10.0),
|
||||
);
|
||||
let (_tel_tx, tel_rx) = watch::channel(mission_executor::Telemetry::default());
|
||||
let upload_spy = Arc::new(UploadSpy::default());
|
||||
let (handle, fsm_join) = executor_only.run(upload_spy.clone(), vec![], tel_rx);
|
||||
|
||||
let (sys_tx, sys_rx) = watch::channel::<Option<UavSysStatus>>(None);
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let monitor = BatteryMonitor::new(BatteryConfig::default());
|
||||
let driver = BatteryDriver::new(monitor, handle.clone(), cmd_spy.clone(), sys_rx)
|
||||
.with_tick_interval(Duration::from_millis(20));
|
||||
let (bh, driver_join) = driver.spawn(shutdown_rx);
|
||||
|
||||
// Apply override BEFORE telemetry arrives.
|
||||
bh.apply_override(BatteryOverride {
|
||||
until: Instant::now() + Duration::from_secs(60),
|
||||
operator_id: "op-9".into(),
|
||||
rationale: "test".into(),
|
||||
})
|
||||
.await
|
||||
.expect("override accepted");
|
||||
|
||||
// Pump telemetry at RTL threshold — override should suppress.
|
||||
sys_tx
|
||||
.send(Some(UavSysStatus {
|
||||
voltage_battery_mv: 11_700,
|
||||
current_battery_ca: 100,
|
||||
battery_remaining: 20,
|
||||
onboard_sensors_health: 0,
|
||||
errors_comm: 0,
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
// Act — give the driver several ticks to evaluate.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Assert — RTL command never issued because override suppressed it.
|
||||
assert_eq!(
|
||||
cmd_spy.rtl_count.load(Ordering::Relaxed),
|
||||
0,
|
||||
"override should suppress RTL"
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
let _ = shutdown_tx.send(true);
|
||||
driver_join.await.ok();
|
||||
fsm_join.abort();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn target_follow_release_recomputes_and_reuploads() {
|
||||
// Arrange
|
||||
let original = vec![wp(0, 50.0, 30.0), wp(1, 50.1, 30.1)];
|
||||
let upload_spy = Arc::new(UploadSpy::default());
|
||||
let planner = MissionRePlanner::new(upload_spy.clone());
|
||||
|
||||
// Act — release from current position 50.05/30.05.
|
||||
let _resume = planner
|
||||
.on_target_follow_release(&original, coord(50.05, 30.05))
|
||||
.await
|
||||
.expect("ok");
|
||||
|
||||
// Assert — upload happened with a 3-waypoint mission (rejoin + 2 originals).
|
||||
let calls = upload_spy.calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].len(), original.len() + 1);
|
||||
assert_eq!(calls[0][0].lat_deg_e7, (50.05 * 1.0e7) as i32);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
fn wp(seq: u16, lat: f64, lon: f64) -> MissionWaypoint {
|
||||
MissionWaypoint {
|
||||
seq,
|
||||
frame: MavFrame::MavFrameGlobalRelativeAlt,
|
||||
command: MavCommand::MavCmdNavWaypoint,
|
||||
current: false,
|
||||
auto_continue: true,
|
||||
param_1: 0.0,
|
||||
param_2: 0.0,
|
||||
param_3: 0.0,
|
||||
param_4: 0.0,
|
||||
lat_deg_e7: (lat * 1.0e7) as i32,
|
||||
lon_deg_e7: (lon * 1.0e7) as i32,
|
||||
alt_m: 50.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the FSM into `target` via telemetry so the existing
|
||||
/// transition table reaches it organically. We use the same
|
||||
/// `Telemetry` flags the multirotor table expects.
|
||||
async fn force_state(
|
||||
handle: &mission_executor::MissionExecutorHandle,
|
||||
target: mission_executor::MissionState,
|
||||
) {
|
||||
use mission_executor::MissionState as S;
|
||||
// The test-only `force_state` helper relies on the same trick the
|
||||
// BIT tests use: bump the in-memory state via the existing
|
||||
// `failsafe_trigger(LinkLost)` path (which already does direct
|
||||
// state mutation) and accept that this is a back-door for tests.
|
||||
// For more permissive forcing we drive a hand-rolled scenario:
|
||||
// construct the handle in `Disconnected`, then call a sequence
|
||||
// of `failsafe_trigger` against an in-memory mutation. Since
|
||||
// `MissionExecutorHandle` does not expose direct state setters
|
||||
// (intentionally), we mutate via a raw debug-only path —
|
||||
// implemented here as a sequence that nudges the state machine.
|
||||
if target == S::FlyMission {
|
||||
// No FSM table will land us in FlyMission without telemetry
|
||||
// gates; use the public failsafe path's seam. Trigger
|
||||
// LinkLost when state == FlyMission to assert behavior.
|
||||
// To get to FlyMission first, we rely on a parallel test
|
||||
// helper: directly insert via a manual override.
|
||||
unsafe_set_state_for_tests(handle, S::FlyMission).await;
|
||||
} else {
|
||||
unsafe_set_state_for_tests(handle, target).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsafe-for-tests state setter. Reaches into the public mutex via
|
||||
/// a `failsafe_trigger`-style code path. Implemented as a test-only
|
||||
/// helper that uses the public API only.
|
||||
async fn unsafe_set_state_for_tests(
|
||||
handle: &mission_executor::MissionExecutorHandle,
|
||||
target: mission_executor::MissionState,
|
||||
) {
|
||||
// We rely on the documented behaviour that `failsafe_trigger`
|
||||
// only transitions when state == FlyMission. To set state to
|
||||
// FlyMission first, we need a back-door. Add one via the
|
||||
// crate-private `force_state_for_tests` (added in lib.rs below).
|
||||
handle.force_state_for_tests(target).await;
|
||||
}
|
||||
Reference in New Issue
Block a user