Files
autopilot/crates/mapobjects_store/tests/ignored_and_sweep.rs
T
Oleksandr Bezdieniezhnykh e56d428753 [AZ-649] [AZ-674] [AZ-667] telemetry + vlm schema + mapobjects hydrate batch 6
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>
2026-05-19 17:40:43 +03:00

246 lines
8.0 KiB
Rust

//! 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(),
uav_id: "uav-az666".into(),
observed_at_monotonic_ns: 0,
}
}
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(&region).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(&region).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());
}