mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 13:11:11 +00:00
aa4282f9f8
Co-authored-by: Cursor <cursoragent@cursor.com>
219 lines
7.1 KiB
Rust
219 lines
7.1 KiB
Rust
//! AZ-654 / AZ-655 / AZ-656 integration tests.
|
|
//!
|
|
//! Each test exercises one batch-11 primitive against the production
|
|
//! `GimbalControllerHandle` surface (set_pose / zoom / state_stream),
|
|
//! catching wiring bugs that the per-primitive unit tests can't see
|
|
//! (e.g. `ts_monotonic_ns` plumbing, transport interaction).
|
|
|
|
use std::net::{Ipv4Addr, SocketAddr};
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use tokio::net::UdpSocket;
|
|
|
|
use gimbal_controller::{
|
|
encode_frame, A40Transport, CentreOnTarget, CentreOnTargetConfig, FrameId, GimbalCommand,
|
|
GimbalController, NextStep, PlanExecutor, SweepConfig, SweepEngine, SweepPattern,
|
|
};
|
|
use shared::models::frame::BoundingBox;
|
|
use shared::models::gimbal::{GimbalState, PanGoal, PanPlan};
|
|
|
|
fn loopback(port: u16) -> SocketAddr {
|
|
SocketAddr::new(Ipv4Addr::new(127, 0, 0, 1).into(), port)
|
|
}
|
|
|
|
fn initial_state() -> GimbalState {
|
|
GimbalState {
|
|
yaw: 0.0,
|
|
pitch: 0.0,
|
|
zoom: 1.0,
|
|
ts_monotonic_ns: 0,
|
|
command_in_flight: false,
|
|
}
|
|
}
|
|
|
|
/// AZ-656 AC-2 — every `set_pose` publishes a `GimbalState` with a
|
|
/// strictly-monotonic `ts_monotonic_ns`. Catches the wrong-clock bug
|
|
/// where `SystemTime::now()` was previously used (would have been
|
|
/// observable as a stale or NTP-adjusted timestamp).
|
|
#[tokio::test]
|
|
async fn az656_set_pose_publishes_monotonic_timestamp() {
|
|
// Arrange — full controller wired to a fake A40 echo loop
|
|
let fake_socket = Arc::new(UdpSocket::bind(loopback(0)).await.expect("fake bind"));
|
|
let fake_addr = fake_socket.local_addr().expect("fake addr");
|
|
let test_socket = Arc::new(UdpSocket::bind(loopback(0)).await.expect("test bind"));
|
|
test_socket.connect(fake_addr).await.expect("connect");
|
|
|
|
let fake_socket_clone = fake_socket.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let mut buf = [0u8; 128];
|
|
let Ok((_, from)) = fake_socket_clone.recv_from(&mut buf).await else {
|
|
return;
|
|
};
|
|
let reply = encode_frame(FrameId::T1F1B1D1, &[0; 12], 0).expect("encode");
|
|
let _ = fake_socket_clone.send_to(&reply, from).await;
|
|
}
|
|
});
|
|
|
|
let (transport, _recv_task) =
|
|
A40Transport::from_socket(test_socket, fake_addr).expect("from_socket");
|
|
let controller = GimbalController::with_transport(initial_state(), transport);
|
|
let handle = controller.handle();
|
|
let state_rx = handle.state_stream();
|
|
|
|
// Act — three sequential set_pose calls; capture the stamps each
|
|
// call publishes onto the watch channel.
|
|
let mut timestamps = Vec::with_capacity(3);
|
|
for i in 0..3 {
|
|
handle
|
|
.set_pose(GimbalCommand {
|
|
yaw_deg: i as f32 * 5.0,
|
|
pitch_deg: 0.0,
|
|
})
|
|
.await
|
|
.expect("set_pose");
|
|
timestamps.push(state_rx.borrow().ts_monotonic_ns);
|
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
|
}
|
|
|
|
// Assert
|
|
assert!(
|
|
timestamps[0] > 0,
|
|
"initial stamp should be > 0 after first set_pose"
|
|
);
|
|
assert!(
|
|
timestamps[1] > timestamps[0],
|
|
"ts not monotonic: {} → {}",
|
|
timestamps[0],
|
|
timestamps[1]
|
|
);
|
|
assert!(
|
|
timestamps[2] > timestamps[1],
|
|
"ts not monotonic: {} → {}",
|
|
timestamps[1],
|
|
timestamps[2]
|
|
);
|
|
}
|
|
|
|
/// AZ-655 integration — load a plan and exercise the executor against
|
|
/// a real wall-clock-driven tick loop; verify the throttle counter
|
|
/// matches the emission ratio.
|
|
#[test]
|
|
fn az655_plan_executor_emits_and_throttles_against_real_clock() {
|
|
// Arrange
|
|
let mut exe = PlanExecutor::new(Duration::from_millis(20));
|
|
let t0 = Instant::now();
|
|
exe.load(
|
|
PanPlan {
|
|
goals: vec![
|
|
PanGoal {
|
|
yaw_deg: -10.0,
|
|
pitch_deg: 0.0,
|
|
zoom: 1.0,
|
|
at_ns: 0,
|
|
},
|
|
PanGoal {
|
|
yaw_deg: 10.0,
|
|
pitch_deg: 0.0,
|
|
zoom: 1.0,
|
|
at_ns: 200_000_000,
|
|
},
|
|
],
|
|
},
|
|
t0,
|
|
)
|
|
.expect("load plan");
|
|
|
|
// Act — 100 ticks at 5 ms cadence over 500 ms
|
|
let mut emits = 0_u64;
|
|
let mut throttled = 0_u64;
|
|
for i in 0..100 {
|
|
match exe.next_step(t0 + Duration::from_millis(i * 5)).unwrap() {
|
|
NextStep::Emit(_) => emits += 1,
|
|
NextStep::Throttled => throttled += 1,
|
|
}
|
|
}
|
|
|
|
// Assert — 20 ms throttle over 500 ms ≈ 25 emissions
|
|
assert!((23..=27).contains(&emits), "emits = {emits}, want ~25");
|
|
assert_eq!(emits + throttled, 100);
|
|
assert_eq!(exe.stats().commands_emitted_total, emits);
|
|
assert_eq!(exe.stats().commands_dropped_to_throttle_total, throttled);
|
|
}
|
|
|
|
/// AZ-654 integration — pendulum sweep produces commands the
|
|
/// controller can accept (matches the `GimbalCommand` contract used by
|
|
/// `set_pose`). No transport wiring needed; this is a contract test.
|
|
#[test]
|
|
fn az654_sweep_engine_emits_gimbal_commands_within_bounds() {
|
|
// Arrange
|
|
let cfg = SweepConfig {
|
|
min_yaw_deg: -45.0,
|
|
max_yaw_deg: 45.0,
|
|
pitch_deg: -15.0,
|
|
step_deg: 3.0,
|
|
dwell: Duration::from_millis(200),
|
|
};
|
|
let mut engine = SweepEngine::new(SweepPattern::Pendulum, cfg).expect("new sweep");
|
|
|
|
// Act + Assert — every emitted command stays inside the envelope
|
|
let mut t = Instant::now();
|
|
for _ in 0..200 {
|
|
let cmd = engine.next_step(t).expect("pendulum tick");
|
|
assert!(cmd.yaw_deg >= -45.0 && cmd.yaw_deg <= 45.0);
|
|
assert!((cmd.pitch_deg - (-15.0)).abs() < 0.001);
|
|
t += Duration::from_millis(50);
|
|
}
|
|
}
|
|
|
|
/// AZ-656 integration — closed-loop convergence smoke against the
|
|
/// public `CentreOnTarget` surface (mirrors the unit-test kinematic
|
|
/// model but uses only the public API; catches re-export drift).
|
|
#[test]
|
|
fn az656_centre_on_target_loop_converges_via_public_api() {
|
|
// Arrange
|
|
let cfg = CentreOnTargetConfig::default();
|
|
let mut ctrl = CentreOnTarget::new(cfg);
|
|
let mut bbox = BoundingBox {
|
|
x_min: 0.70,
|
|
y_min: 0.50,
|
|
x_max: 0.80,
|
|
y_max: 0.60,
|
|
};
|
|
let mut yaw = 0.0_f32;
|
|
let mut pitch = 0.0_f32;
|
|
let zoom = 1.0_f32;
|
|
let fov = cfg.fov_deg_at_zoom1 / zoom;
|
|
|
|
// Act
|
|
for _ in 0..3 {
|
|
let out = ctrl.tick(Some(bbox), yaw, pitch, zoom);
|
|
let cmd = out.command.expect("emit");
|
|
let dy = cmd.yaw_deg - yaw;
|
|
let dp = cmd.pitch_deg - pitch;
|
|
yaw = cmd.yaw_deg;
|
|
pitch = cmd.pitch_deg;
|
|
let cx = (bbox.x_min + bbox.x_max) * 0.5 - dy / fov;
|
|
let cy = (bbox.y_min + bbox.y_max) * 0.5 + dp / fov;
|
|
bbox = BoundingBox {
|
|
x_min: cx - 0.05,
|
|
y_min: cy - 0.05,
|
|
x_max: cx + 0.05,
|
|
y_max: cy + 0.05,
|
|
};
|
|
}
|
|
let final_cx = (bbox.x_min + bbox.x_max) * 0.5;
|
|
let final_cy = (bbox.y_min + bbox.y_max) * 0.5;
|
|
|
|
// Assert
|
|
assert!(
|
|
(0.375..=0.625).contains(&final_cx),
|
|
"x = {final_cx} outside centre 25%"
|
|
);
|
|
assert!(
|
|
(0.375..=0.625).contains(&final_cy),
|
|
"y = {final_cy} outside centre 25%"
|
|
);
|
|
}
|