mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 08:41:09 +00:00
e56d428753
AZ-649 mission_executor telemetry forwarding: - shared::models::telemetry::UavTelemetry canonical model - TelemetryForwarder with atomic ArcSwap snapshot + 3 lossy tokio::sync::broadcast channels (MissionExecutor, ScanController, MavlinkUplink) + per-consumer drop counters - MavlinkProjection::from_mavlink for HEARTBEAT/GLOBAL_POSITION_INT/ ATTITUDE/SYS_STATUS - spawn_mavlink_pump bridges mavlink_layer into the forwarder at the binary edge AZ-674 vlm_client schema validation + model_version tracking: - AssessmentParser owns schema validation + model-version state - wire::read_response_raw splits raw bytes from parsing so invalid payloads can be logged size-capped - VlmStatus gains an Inconclusive variant; exhaustive-match test guards downstream consumers - VlmPipelineStatus mirrors the new variant in shared::models::poi AZ-667 mapobjects_store hydrate + pending logs + cascade: - SyncState enum aligned with description.md (FreshBoot, Synced, CachedFallback, Degraded, Failed) - Store::hydrate(MapObjectsBundle) replaces in-memory map atomically; freshness=Stale -> CachedFallback - classify() + end_of_pass append MapObjectObservation events to pending_observations (New/Moved/Existing/RemovedCandidate) - apply_decline + LocalAppended ignored items append to pending_ignored - drain_pending() returns and clears both logs - cascade_mission(id) purges by_cell + IgnoredSet + pending logs - Health surface reports sync_state, pending_obs, pending_ign Co-authored-by: Cursor <cursoragent@cursor.com>
361 lines
10 KiB
Rust
361 lines
10 KiB
Rust
//! AZ-667 acceptance tests — pre-flight hydrate, sync_state machine,
|
|
//! pending observation/ignored append logs, mission cascade.
|
|
|
|
use chrono::Utc;
|
|
use mapobjects_store::{ClassifyInput, MapObjectsStore, MapObjectsStoreConfig, SyncState};
|
|
use shared::models::mapobject::{
|
|
BundleFreshness, IgnoredItem, IgnoredItemSource, MapObject, MapObjectSource, MapObjectsBundle,
|
|
RetentionScope,
|
|
};
|
|
use shared::models::mission::Coordinate;
|
|
use uuid::Uuid;
|
|
|
|
const ANCHOR_LAT: f64 = 50.450_000;
|
|
const ANCHOR_LON: f64 = 30.520_000;
|
|
|
|
fn input(lat: f64, lon: f64, class: &str, mission_id: &str) -> ClassifyInput {
|
|
ClassifyInput {
|
|
gps_lat: lat,
|
|
gps_lon: lon,
|
|
mgrs: format!("MGRS({lat:.6},{lon:.6})"),
|
|
class: class.into(),
|
|
size_width_m: 2.0,
|
|
size_length_m: 2.0,
|
|
confidence: 0.9,
|
|
mission_id: mission_id.into(),
|
|
observed_at: Utc::now(),
|
|
uav_id: "uav-az667".into(),
|
|
observed_at_monotonic_ns: 1_234_567_890,
|
|
}
|
|
}
|
|
|
|
fn map_object(lat: f64, lon: f64, class: &str, mission_id: &str) -> MapObject {
|
|
MapObject {
|
|
h3_cell: 0,
|
|
mgrs_key: format!("MGRS({lat:.6},{lon:.6})"),
|
|
class: class.into(),
|
|
class_group: class.into(),
|
|
gps_lat: lat,
|
|
gps_lon: lon,
|
|
size_width_m: 2.0,
|
|
size_length_m: 2.0,
|
|
confidence: 0.9,
|
|
first_seen: Utc::now(),
|
|
last_seen: Utc::now(),
|
|
mission_id: mission_id.into(),
|
|
source: MapObjectSource::CentralPulled,
|
|
pending_upload: false,
|
|
}
|
|
}
|
|
|
|
fn ignored(mgrs: &str, class_group: &str, mission_id: &str) -> IgnoredItem {
|
|
IgnoredItem {
|
|
id: Uuid::new_v4(),
|
|
mgrs: mgrs.into(),
|
|
h3_cell: 0,
|
|
class_group: class_group.into(),
|
|
decline_time: Utc::now(),
|
|
operator_id: None,
|
|
mission_id: mission_id.into(),
|
|
retention_scope: RetentionScope::Mission,
|
|
expires_at: None,
|
|
source: IgnoredItemSource::CentralPulled,
|
|
pending_upload: false,
|
|
}
|
|
}
|
|
|
|
fn bundle(
|
|
mission_id: &str,
|
|
map_objects: Vec<MapObject>,
|
|
ignored_items: Vec<IgnoredItem>,
|
|
freshness: Option<BundleFreshness>,
|
|
) -> MapObjectsBundle {
|
|
MapObjectsBundle {
|
|
schema_version: "1.0".into(),
|
|
mission_id: mission_id.into(),
|
|
bbox: [
|
|
Coordinate {
|
|
latitude: ANCHOR_LAT + 0.5,
|
|
longitude: ANCHOR_LON - 0.5,
|
|
altitude_m: 0.0,
|
|
},
|
|
Coordinate {
|
|
latitude: ANCHOR_LAT - 0.5,
|
|
longitude: ANCHOR_LON + 0.5,
|
|
altitude_m: 0.0,
|
|
},
|
|
],
|
|
map_objects,
|
|
observations: Vec::new(),
|
|
ignored_items,
|
|
as_of: Utc::now(),
|
|
freshness,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// AC-1: Hydrate from bundle → store contains N + M entries, sync_state
|
|
// = synced.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ac1_hydrate_loads_bundle_and_sets_synced() {
|
|
// Arrange
|
|
let store = MapObjectsStore::default();
|
|
let h = store.handle();
|
|
let b = bundle(
|
|
"m-az667",
|
|
vec![
|
|
map_object(ANCHOR_LAT, ANCHOR_LON, "tank", "m-az667"),
|
|
map_object(ANCHOR_LAT + 0.001, ANCHOR_LON, "truck", "m-az667"),
|
|
],
|
|
vec![ignored("MGRS-X", "tank", "m-az667")],
|
|
Some(BundleFreshness::Fresh),
|
|
);
|
|
|
|
// Act
|
|
h.hydrate(b).unwrap();
|
|
|
|
// Assert
|
|
assert_eq!(h.len().unwrap(), 2);
|
|
assert_eq!(h.sync_state().unwrap(), SyncState::Synced);
|
|
assert!(h.is_ignored("MGRS-X", "tank").unwrap());
|
|
assert!(h.last_pull_ts().unwrap().is_some());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// AC-2: Fallback bundle (freshness = Stale) → sync_state =
|
|
// CachedFallback.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ac2_stale_bundle_sets_cached_fallback() {
|
|
// Arrange
|
|
let store = MapObjectsStore::default();
|
|
let h = store.handle();
|
|
let b = bundle(
|
|
"m-az667",
|
|
vec![map_object(ANCHOR_LAT, ANCHOR_LON, "tank", "m-az667")],
|
|
Vec::new(),
|
|
Some(BundleFreshness::Stale),
|
|
);
|
|
|
|
// Act
|
|
h.hydrate(b).unwrap();
|
|
|
|
// Assert
|
|
assert_eq!(h.sync_state().unwrap(), SyncState::CachedFallback);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// AC-3: Classify appends pending observation.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ac3_classify_appends_pending_observation() {
|
|
// Arrange
|
|
let cfg = MapObjectsStoreConfig {
|
|
distance_threshold_m: 5.0,
|
|
move_threshold_m: 50.0,
|
|
..MapObjectsStoreConfig::default()
|
|
};
|
|
let store = MapObjectsStore::new(cfg);
|
|
let h = store.handle();
|
|
let b = bundle(
|
|
"m-az667",
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Some(BundleFreshness::Fresh),
|
|
);
|
|
h.hydrate(b).unwrap();
|
|
assert_eq!(h.pending_observations_count().unwrap(), 0);
|
|
|
|
// Act
|
|
let _ = h
|
|
.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank", "m-az667"))
|
|
.unwrap();
|
|
|
|
// Assert
|
|
assert_eq!(h.pending_observations_count().unwrap(), 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// AC-3b: Operator decline appends to pending_ignored.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ac3b_local_decline_appends_to_pending_ignored() {
|
|
use chrono::Duration as ChronoDuration;
|
|
use shared::models::poi::{Poi, VlmPipelineStatus};
|
|
// Arrange
|
|
let store = MapObjectsStore::default();
|
|
let h = store.handle();
|
|
let now = Utc::now();
|
|
let poi = Poi {
|
|
id: Uuid::new_v4(),
|
|
confidence: 0.85,
|
|
mgrs: "MGRS-DECLINED".into(),
|
|
class: "concealed_position".into(),
|
|
class_group: "concealed_position_group".into(),
|
|
source_detection_ids: Vec::new(),
|
|
enqueued_at: now,
|
|
priority: 1.0,
|
|
decline_suppressed: false,
|
|
vlm_status: VlmPipelineStatus::NotRequested,
|
|
tier2_evidence: None,
|
|
deadline: now + ChronoDuration::seconds(60),
|
|
};
|
|
|
|
// Act
|
|
h.apply_decline(poi).unwrap();
|
|
|
|
// Assert
|
|
assert_eq!(h.pending_ignored_count().unwrap(), 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// AC-4: drain_pending returns and clears pending.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ac4_drain_pending_clears_counts() {
|
|
// Arrange
|
|
let cfg = MapObjectsStoreConfig {
|
|
distance_threshold_m: 5.0,
|
|
move_threshold_m: 50.0,
|
|
..MapObjectsStoreConfig::default()
|
|
};
|
|
let store = MapObjectsStore::new(cfg);
|
|
let h = store.handle();
|
|
let b = bundle(
|
|
"m-az667",
|
|
Vec::new(),
|
|
Vec::new(),
|
|
Some(BundleFreshness::Fresh),
|
|
);
|
|
h.hydrate(b).unwrap();
|
|
|
|
h.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank", "m-az667"))
|
|
.unwrap();
|
|
h.classify(input(ANCHOR_LAT + 0.001, ANCHOR_LON, "truck", "m-az667"))
|
|
.unwrap();
|
|
h.append_ignored(IgnoredItem {
|
|
source: IgnoredItemSource::LocalAppended,
|
|
..ignored("MGRS-Y", "tank", "m-az667")
|
|
})
|
|
.unwrap();
|
|
assert_eq!(h.pending_observations_count().unwrap(), 2);
|
|
assert_eq!(h.pending_ignored_count().unwrap(), 1);
|
|
|
|
// Act
|
|
let (obs, ign) = h.drain_pending().unwrap();
|
|
|
|
// Assert
|
|
assert_eq!(obs.len(), 2);
|
|
assert_eq!(ign.len(), 1);
|
|
assert_eq!(h.pending_observations_count().unwrap(), 0);
|
|
assert_eq!(h.pending_ignored_count().unwrap(), 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// AC-5: cascade_mission drops mission-scoped objects but preserves
|
|
// objects belonging to a different mission.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ac5_cascade_mission_drops_only_matching_objects() {
|
|
// Arrange
|
|
let store = MapObjectsStore::default();
|
|
let h = store.handle();
|
|
let b = bundle(
|
|
"m-A",
|
|
vec![
|
|
map_object(ANCHOR_LAT, ANCHOR_LON, "tank", "m-A"),
|
|
map_object(ANCHOR_LAT + 0.001, ANCHOR_LON, "truck", "m-B"),
|
|
],
|
|
vec![
|
|
ignored("MGRS-A", "tank", "m-A"),
|
|
ignored("MGRS-B", "truck", "m-B"),
|
|
],
|
|
Some(BundleFreshness::Fresh),
|
|
);
|
|
h.hydrate(b).unwrap();
|
|
assert_eq!(h.len().unwrap(), 2);
|
|
|
|
// Act
|
|
h.cascade_mission("m-A").unwrap();
|
|
|
|
// Assert
|
|
assert_eq!(h.len().unwrap(), 1);
|
|
assert!(!h.is_ignored("MGRS-A", "tank").unwrap());
|
|
assert!(h.is_ignored("MGRS-B", "truck").unwrap());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// End-of-pass removed candidates land in pending observations.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn end_of_pass_appends_removed_candidate_to_pending() {
|
|
// Arrange
|
|
let cfg = MapObjectsStoreConfig {
|
|
distance_threshold_m: 5.0,
|
|
move_threshold_m: 50.0,
|
|
..MapObjectsStoreConfig::default()
|
|
};
|
|
let store = MapObjectsStore::new(cfg);
|
|
let h = store.handle();
|
|
let _ = h
|
|
.classify(input(ANCHOR_LAT, ANCHOR_LON, "tank", "m-az667"))
|
|
.unwrap();
|
|
// Drain the NEW observation so the pass adds exactly one new row.
|
|
let _ = h.drain_pending().unwrap();
|
|
|
|
let region = [
|
|
Coordinate {
|
|
latitude: ANCHOR_LAT + 0.01,
|
|
longitude: ANCHOR_LON - 0.01,
|
|
altitude_m: 0.0,
|
|
},
|
|
Coordinate {
|
|
latitude: ANCHOR_LAT - 0.01,
|
|
longitude: ANCHOR_LON + 0.01,
|
|
altitude_m: 0.0,
|
|
},
|
|
];
|
|
|
|
// Act
|
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
|
h.pass_start(region).unwrap();
|
|
let removed = h.end_of_pass(®ion).unwrap();
|
|
|
|
// Assert
|
|
assert_eq!(removed.len(), 1);
|
|
let (obs, _) = h.drain_pending().unwrap();
|
|
assert_eq!(obs.len(), 1);
|
|
assert!(matches!(
|
|
obs[0].diff_kind,
|
|
shared::models::mapobject::DiffKind::RemovedCandidate
|
|
));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// mark_pushed_ok records last_push_ts and resets to Synced.
|
|
// ---------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn mark_pushed_ok_records_timestamp() {
|
|
// Arrange
|
|
let store = MapObjectsStore::default();
|
|
let h = store.handle();
|
|
h.set_sync_state(SyncState::Degraded).unwrap();
|
|
assert!(h.last_push_ts().unwrap().is_none());
|
|
|
|
// Act
|
|
h.mark_pushed_ok().unwrap();
|
|
|
|
// Assert
|
|
assert_eq!(h.sync_state().unwrap(), SyncState::Synced);
|
|
assert!(h.last_push_ts().unwrap().is_some());
|
|
}
|