Files
Oleksandr Bezdieniezhnykh c4eff40dbc [AZ-680] [AZ-681] operator_bridge command dispatch + safety lane
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>
2026-05-20 17:32:59 +03:00

226 lines
7.0 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(_)));
}