Files
autopilot/crates/mapobjects_store/tests/ignored_and_sweep.rs
T
Oleksandr Bezdieniezhnykh b5cc0c321c
ci/woodpecker/push/build-arm Pipeline failed
[AZ-666] [AZ-673] [AZ-648] ignored set + UDS VLM + mission FSM batch 5
AZ-666 mapobjects_store:
- internal/ignored.rs (HashSet<(mgrs, class_group)> for O(1) suppression)
- internal/passes.rs (per-region PassTracker with observed-id set and
  end-of-pass removed-candidate sweep)
- Classification::Ignored wired into classify; apply_decline +
  is_ignored + pass_start + end_of_pass on MapObjectsStoreHandle
- new tests/ignored_and_sweep.rs (3 AC + 2 supplementary)

AZ-673 vlm_client:
- internal/peer_cred.rs (Linux SO_PEERCRED via libc getsockopt;
  PeerCredOutcome::SkippedNonLinux on macOS dev hosts per
  description.md §8)
- internal/prompt.rs (pre-send ROI size + format + prompt
  non-emptiness validation)
- internal/wire.rs (length-prefixed JSON envelope with base64 ROI)
- internal/uds_client.rs (tokio UnixStream client; bounded
  reconnect; hard-stop on peer-cred mismatch; per-request deadline)
- VlmClient with both eager (open/connect) and lazy (new) ctor
- workspace Cargo.toml: base64 + libc as workspace deps

AZ-648 mission_executor:
- internal/types.rs (Variant, MissionState, TransitionKey,
  Telemetry, TransitionEvent, StepOutcome)
- internal/driver.rs (MissionDriver trait + DriverError +
  DriverAction)
- internal/fsm.rs (variant-agnostic Transition + FsmCore + step_one
  with per-transition retry budget keyed by TransitionKey)
- internal/multirotor.rs + internal/fixed_wing.rs (typed transition
  tables; multirotor has Armed/TakeOff, fixed-wing parks in
  WaitAuto for operator AUTO)
- public API: MissionExecutor::run spawns the FSM task and returns
  a clone-safe MissionExecutorHandle (state, health, subscribe,
  paused_reason, retry_count)
- new tests/state_machine.rs (AC-1..AC-4 via ScriptedDriver fake;
  SITL conformance lands with AZ-649 telemetry forwarding)

Workspace: cargo fmt + clippy -D warnings clean; full
cargo test --workspace --all-features green (1 ignored = AZ-665
perf gate). Tasks moved todo/ → done/, autodev state set to batch
6 selection.

Refs: _docs/03_implementation/batch_05_cycle1_report.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 16:54:00 +03:00

244 lines
7.9 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(),
}
}
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());
}