//! 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, ignored_items: Vec, freshness: Option, ) -> 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()); }