//! AZ-666 acceptance tests — `IgnoredItem` set + end-of-pass sweep. use chrono::{Duration as ChronoDuration, Utc}; use mapobjects_store::{ Classification, ClassifyInput, MapObjectsStore, MapObjectsStoreConfig, RegionBbox, }; use shared::models::mapobject::{IgnoredItem, IgnoredItemSource, RetentionScope}; use shared::models::mission::Coordinate; use uuid::Uuid; const M_PER_DEG_LAT: f64 = 111_320.0; fn m_per_deg_lon(lat_deg: f64) -> f64 { M_PER_DEG_LAT * lat_deg.to_radians().cos() } fn shift_m(base_lat: f64, base_lon: f64, dn_m: f64, de_m: f64) -> (f64, f64) { ( base_lat + dn_m / M_PER_DEG_LAT, base_lon + de_m / m_per_deg_lon(base_lat), ) } fn input(lat: f64, lon: f64, class: &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: "m-az666".into(), observed_at: Utc::now(), } } fn ignored(mgrs: &str, class_group: &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: "m-az666".into(), retention_scope: RetentionScope::Mission, expires_at: None, source: IgnoredItemSource::LocalAppended, pending_upload: true, } } fn bbox(nw_lat: f64, nw_lon: f64, se_lat: f64, se_lon: f64) -> RegionBbox { [ Coordinate { latitude: nw_lat, longitude: nw_lon, altitude_m: 0.0, }, Coordinate { latitude: se_lat, longitude: se_lon, altitude_m: 0.0, }, ] } const ANCHOR_LAT: f64 = 50.450_000; const ANCHOR_LON: f64 = 30.520_000; // --------------------------------------------------------------------- // AC-1: append(IgnoredItem { mgrs, class_group }) → is_ignored returns true. // --------------------------------------------------------------------- #[test] fn ac1_ignored_item_suppresses_lookup() { // Arrange let store = MapObjectsStore::default(); let h = store.handle(); // Act h.append_ignored(ignored("MGRS-A", "concealed_position_group")) .unwrap(); // Assert assert!(h.is_ignored("MGRS-A", "concealed_position_group").unwrap()); assert!(!h.is_ignored("MGRS-A", "movement_candidate").unwrap()); assert!(!h.is_ignored("MGRS-B", "concealed_position_group").unwrap()); } // --------------------------------------------------------------------- // classify() with an ignored (mgrs, class_group) returns Classification::Ignored // and DOES NOT insert into the store. // --------------------------------------------------------------------- #[test] fn classify_returns_ignored_when_pair_is_in_set() { // Arrange let store = MapObjectsStore::default(); let h = store.handle(); let input = input(ANCHOR_LAT, ANCHOR_LON, "tank"); h.append_ignored(ignored(&input.mgrs, "tank")).unwrap(); // Act let c = h.classify(input).unwrap(); // Assert assert!(matches!(c, Classification::Ignored), "got {c:?}"); assert_eq!(h.len().unwrap(), 0); } // --------------------------------------------------------------------- // AC-2: end_of_pass returns objects un-observed during the pass. // --------------------------------------------------------------------- #[test] fn ac2_end_of_pass_returns_un_observed() { // 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(); // Seed three objects M1, M2, M3 spaced 50 m apart. let (m1_lat, m1_lon) = (ANCHOR_LAT, ANCHOR_LON); let (m2_lat, m2_lon) = shift_m(ANCHOR_LAT, ANCHOR_LON, 50.0, 0.0); let (m3_lat, m3_lon) = shift_m(ANCHOR_LAT, ANCHOR_LON, 100.0, 0.0); let m1_in = input(m1_lat, m1_lon, "tank"); let m1_mgrs = m1_in.mgrs.clone(); let _ = h.classify(m1_in).unwrap(); let m2_in = input(m2_lat, m2_lon, "tank"); let m2_mgrs = m2_in.mgrs.clone(); let _ = h.classify(m2_in).unwrap(); let m3_in = input(m3_lat, m3_lon, "tank"); let m3_mgrs = m3_in.mgrs.clone(); let _ = h.classify(m3_in).unwrap(); let region = bbox( ANCHOR_LAT + 0.01, ANCHOR_LON - 0.01, ANCHOR_LAT - 0.01, ANCHOR_LON + 0.01, ); // Act — open pass, re-observe only M1, close pass. // Backdate seeded last_seen so the un-observed objects qualify. // (Pass tracker only flags objects whose last_seen <= pass.started_at; // since we just inserted them, advance the wall clock by sleeping is // expensive, so instead start the pass with an as-of slightly in // the future relative to the seeded timestamps.) std::thread::sleep(std::time::Duration::from_millis(2)); h.pass_start(region).unwrap(); // Re-observe M1 via a small ε offset so it stays an Existing match. let (m1_obs_lat, m1_obs_lon) = shift_m(m1_lat, m1_lon, 0.5, 0.0); let again = h.classify(input(m1_obs_lat, m1_obs_lon, "tank")).unwrap(); assert!(matches!(again, Classification::Existing { .. })); let removed = h.end_of_pass(®ion).unwrap(); // Assert let mgrs_seen: Vec<_> = removed.iter().map(|r| r.mgrs.clone()).collect(); assert!( mgrs_seen.contains(&m2_mgrs), "expected M2 in removed candidates, got {mgrs_seen:?}", ); assert!(mgrs_seen.contains(&m3_mgrs)); assert!(!mgrs_seen.contains(&m1_mgrs)); assert_eq!(removed.len(), 2); } // --------------------------------------------------------------------- // AC-3: end_of_pass excludes ignored objects from the candidate list. // --------------------------------------------------------------------- #[test] fn ac3_end_of_pass_excludes_ignored() { // 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 m2_in = input(ANCHOR_LAT, ANCHOR_LON, "tank"); let m2_mgrs = m2_in.mgrs.clone(); let _ = h.classify(m2_in).unwrap(); let region = bbox( ANCHOR_LAT + 0.01, ANCHOR_LON - 0.01, ANCHOR_LAT - 0.01, ANCHOR_LON + 0.01, ); h.append_ignored(ignored(&m2_mgrs, "tank")).unwrap(); // Act std::thread::sleep(std::time::Duration::from_millis(2)); h.pass_start(region).unwrap(); let removed = h.end_of_pass(®ion).unwrap(); // Assert — M2 was un-observed during the pass but ignored, so it // MUST NOT be surfaced. assert!( removed.iter().all(|r| r.mgrs != m2_mgrs), "ignored object leaked into removed candidates: {removed:?}", ); } // --------------------------------------------------------------------- // apply_decline(POI) installs the equivalent IgnoredItem. // --------------------------------------------------------------------- #[test] fn apply_decline_suppresses_subsequent_detections() { 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.9, mgrs: "MGRS-DECLINED".into(), class: "concealed_position".into(), class_group: "concealed_position_group".into(), source_detection_ids: vec![], 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!(h .is_ignored("MGRS-DECLINED", "concealed_position_group") .unwrap()); }