mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 10:11:09 +00:00
c4eff40dbc
Add the operator-command dispatcher behind a typed CommandAck: 60 s per-command-id idempotency cache, surfaced-POI registry with unknown_poi_id + expired gates, BIT-degraded ack severity check, and SafetyOverride forwarding to mission_executor with structured audit log (redacts signature + session_token). Cross-layer wiring goes through three new traits in shared::contracts (ScanCommandRouter, MissionSafetyRouter, BitReportSeverityLookup) so operator_bridge stays free of direct scan_controller / mission_executor imports. scan_controller::ScanControllerHandle implements the scan router; a new mission_executor::SafetyDispatchHandle wraps the BIT ack channel + battery monitor handle and implements the safety router; BitControllerHandle gains a bounded (16-entry) report-severity cache for the lookup trait. scan_controller also picks up ConfirmPoi handling: PoiQueue::confirm removes the entry and SubmitOutcome::Confirmed carries the typed (target_mgrs, target_class) hint for AZ-684/AZ-686 downstream. Tests: 9 new integration tests in operator_bridge/tests/dispatcher.rs cover AZ-680 AC-1..AC-5 + AZ-681 AC-1..AC-4. scan_controller adds 2 ConfirmPoi tests. All modified-crate suites green; one pre-existing mission_executor state-machine test flake (already documented in _docs/_process_leftovers) updated to note ac1 also affected. Co-authored-by: Cursor <cursoragent@cursor.com>
226 lines
7.0 KiB
Rust
226 lines
7.0 KiB
Rust
//! AZ-683 integration tests — POI queue + rate cap + decision-window
|
||
//! mapping exercised through the public `ScanControllerHandle` API.
|
||
|
||
use chrono::{Duration as ChronoDur, Utc};
|
||
use serde_json::json;
|
||
use uuid::Uuid;
|
||
|
||
use scan_controller::{decision_window, ScanController, SubmitOutcome, SURFACE_CAP_PER_WINDOW};
|
||
use shared::models::operator::{OperatorCommand, OperatorCommandKind};
|
||
use shared::models::poi::{Poi, VlmPipelineStatus};
|
||
|
||
fn poi(confidence: f32, mgrs: &str) -> Poi {
|
||
Poi {
|
||
id: Uuid::new_v4(),
|
||
confidence,
|
||
mgrs: mgrs.to_string(),
|
||
class: "tank".to_string(),
|
||
class_group: "armor".to_string(),
|
||
source_detection_ids: vec![],
|
||
enqueued_at: Utc::now(),
|
||
priority: 0.0,
|
||
decline_suppressed: false,
|
||
vlm_status: VlmPipelineStatus::NotRequested,
|
||
tier2_evidence: None,
|
||
deadline: Utc::now() + ChronoDur::seconds(60),
|
||
}
|
||
}
|
||
|
||
/// AC-1 — priority ordering through the public API.
|
||
#[tokio::test]
|
||
async fn ac1_priority_ordering_via_handle() {
|
||
// Arrange
|
||
let h = ScanController::new().handle();
|
||
let p1 = poi(0.9, "1");
|
||
let p2 = poi(0.6, "2");
|
||
h.submit_poi_candidate(p1.clone(), 0.5).await;
|
||
h.submit_poi_candidate(p2.clone(), 0.9).await;
|
||
|
||
// Act
|
||
let first = h.next_poi_for_surface().await.expect("first surface");
|
||
|
||
// Assert — p2 (0.6 × 0.9 = 0.54) outranks p1 (0.9 × 0.5 = 0.45).
|
||
assert_eq!(first.id, p2.id);
|
||
}
|
||
|
||
/// AC-2 — the 5/min cap holds back excess POIs.
|
||
#[tokio::test]
|
||
async fn ac2_five_per_minute_cap_via_handle() {
|
||
// Arrange
|
||
let h = ScanController::new().handle();
|
||
for i in 0..10 {
|
||
h.submit_poi_candidate(poi(0.8, &format!("m{i}")), 0.5)
|
||
.await;
|
||
}
|
||
|
||
// Act
|
||
let mut surfaced = 0;
|
||
for _ in 0..10 {
|
||
if h.next_poi_for_surface().await.is_some() {
|
||
surfaced += 1;
|
||
}
|
||
}
|
||
|
||
// Assert
|
||
assert_eq!(surfaced, SURFACE_CAP_PER_WINDOW);
|
||
assert_eq!(h.pois_in_window().await, SURFACE_CAP_PER_WINDOW);
|
||
assert_eq!(h.poi_queue_len().await, 5);
|
||
}
|
||
|
||
/// AC-3 — decision window linear mapping is exported via the
|
||
/// `decision_window` re-export. (The pure logic is tested in unit;
|
||
/// this is the smoke test that the public function is wired up.)
|
||
#[tokio::test]
|
||
async fn ac3_decision_window_public_mapping() {
|
||
// Assert
|
||
assert_eq!(
|
||
decision_window(0.40).unwrap().as_secs(),
|
||
30,
|
||
"floor maps to 30 s"
|
||
);
|
||
assert_eq!(
|
||
decision_window(1.00).unwrap().as_secs(),
|
||
120,
|
||
"ceiling maps to 120 s"
|
||
);
|
||
assert!(decision_window(0.39).is_none(), "sub-floor returns None");
|
||
}
|
||
|
||
/// AC-4 — POIs below 40 % confidence enqueue but never surface.
|
||
#[tokio::test]
|
||
async fn ac4_below_floor_never_surfaces() {
|
||
// Arrange
|
||
let h = ScanController::new().handle();
|
||
h.submit_poi_candidate(poi(0.39, "low"), 0.9).await;
|
||
h.submit_poi_candidate(poi(0.20, "lower"), 0.9).await;
|
||
|
||
// Act
|
||
let surfaced = h.next_poi_for_surface().await;
|
||
|
||
// Assert
|
||
assert!(surfaced.is_none(), "sub-40% POIs must not surface");
|
||
assert_eq!(h.poi_queue_len().await, 2, "POIs remain in queue");
|
||
}
|
||
|
||
/// AC-5 — timeout sweep forgets expired POIs without emitting any
|
||
/// IgnoredItem.
|
||
#[tokio::test]
|
||
async fn ac5_tick_sweep_forgets_expired_pois() {
|
||
// Arrange — POI with an already-expired deadline.
|
||
let h = ScanController::new().handle();
|
||
let mut p = poi(0.8, "expired");
|
||
p.deadline = Utc::now() - ChronoDur::seconds(1);
|
||
h.submit_poi_candidate(p, 0.5).await;
|
||
assert_eq!(h.poi_queue_len().await, 1);
|
||
|
||
// Act
|
||
h.tick().await.expect("tick");
|
||
|
||
// Assert
|
||
assert_eq!(h.poi_queue_len().await, 0);
|
||
let metrics = h.metrics().await;
|
||
assert_eq!(metrics.pois_forgotten_total, 1);
|
||
assert_eq!(metrics.pois_declined_total, 0, "no IgnoredItem on timeout");
|
||
}
|
||
|
||
/// DeclinePoi via operator command returns a `SubmitOutcome::Declined`
|
||
/// carrying the IgnoredItem payload AZ-685 will persist.
|
||
#[tokio::test]
|
||
async fn decline_poi_via_operator_command_emits_action() {
|
||
// Arrange
|
||
let h = ScanController::new().handle();
|
||
let p = poi(0.8, "decline-me");
|
||
let id = p.id;
|
||
h.submit_poi_candidate(p, 0.5).await;
|
||
|
||
let cmd = OperatorCommand {
|
||
command_id: Uuid::new_v4(),
|
||
session_token: "s".to_string(),
|
||
sequence_number: 1,
|
||
issued_at_wallclock: Utc::now(),
|
||
kind: OperatorCommandKind::DeclinePoi,
|
||
payload: json!({ "poi_id": id.to_string() }),
|
||
signature: vec![],
|
||
};
|
||
|
||
// Act
|
||
let outcome = h.submit_operator_cmd(cmd).await.expect("decline accepted");
|
||
|
||
// Assert
|
||
match outcome {
|
||
SubmitOutcome::Declined(action) => {
|
||
assert_eq!(action.poi_id, id);
|
||
assert_eq!(action.mgrs, "decline-me");
|
||
assert_eq!(action.class_group, "armor");
|
||
}
|
||
other => panic!("decline must return Declined action, got {other:?}"),
|
||
}
|
||
assert_eq!(h.poi_queue_len().await, 0);
|
||
}
|
||
|
||
/// AZ-680 — ConfirmPoi via operator command returns
|
||
/// `SubmitOutcome::Confirmed` with the typed target hint and drains
|
||
/// the POI from the queue.
|
||
#[tokio::test]
|
||
async fn confirm_poi_via_operator_command_emits_action() {
|
||
// Arrange
|
||
let h = ScanController::new().handle();
|
||
let p = poi(0.8, "confirm-me");
|
||
let id = p.id;
|
||
let expected_class = p.class.clone();
|
||
let expected_group = p.class_group.clone();
|
||
h.submit_poi_candidate(p, 0.5).await;
|
||
|
||
let cmd = OperatorCommand {
|
||
command_id: Uuid::new_v4(),
|
||
session_token: "s".to_string(),
|
||
sequence_number: 1,
|
||
issued_at_wallclock: Utc::now(),
|
||
kind: OperatorCommandKind::ConfirmPoi,
|
||
payload: json!({ "poi_id": id.to_string() }),
|
||
signature: vec![],
|
||
};
|
||
|
||
// Act
|
||
let outcome = h.submit_operator_cmd(cmd).await.expect("confirm accepted");
|
||
|
||
// Assert
|
||
match outcome {
|
||
SubmitOutcome::Confirmed(action) => {
|
||
assert_eq!(action.poi_id, id);
|
||
assert_eq!(action.target_mgrs, "confirm-me");
|
||
assert_eq!(action.target_class, expected_class);
|
||
assert_eq!(action.class_group, expected_group);
|
||
}
|
||
other => panic!("confirm must return Confirmed action, got {other:?}"),
|
||
}
|
||
assert_eq!(h.poi_queue_len().await, 0);
|
||
}
|
||
|
||
/// AZ-680 — ConfirmPoi for an unknown poi_id must NOT silently
|
||
/// succeed. Returns a `Validation` error so `operator_bridge` can
|
||
/// surface a typed NACK to the operator UI.
|
||
#[tokio::test]
|
||
async fn confirm_poi_unknown_id_is_validation_error() {
|
||
// Arrange
|
||
let h = ScanController::new().handle();
|
||
let cmd = OperatorCommand {
|
||
command_id: Uuid::new_v4(),
|
||
session_token: "s".to_string(),
|
||
sequence_number: 1,
|
||
issued_at_wallclock: Utc::now(),
|
||
kind: OperatorCommandKind::ConfirmPoi,
|
||
payload: json!({ "poi_id": Uuid::new_v4().to_string() }),
|
||
signature: vec![],
|
||
};
|
||
|
||
// Act
|
||
let err = h
|
||
.submit_operator_cmd(cmd)
|
||
.await
|
||
.expect_err("unknown poi must error");
|
||
|
||
// Assert
|
||
assert!(matches!(err, shared::error::AutopilotError::Validation(_)));
|
||
}
|