use std::sync::Arc;
use action_core::{
ActionClient, ActionLiveness, ActionSchema, ActionServer, BasicActionClient, BasicActionServer,
GoalStatus, client_server::ActionChannel,
};
use core_types::ActionGoalId;
use core_types::Timestamp;
use data_model::SchemaDescriptor;
fn schema() -> ActionSchema {
ActionSchema {
goal: SchemaDescriptor::new("nav.Goal", 1, "NavigationGoal"),
feedback: SchemaDescriptor::new("nav.Feedback", 1, "NavigationFeedback"),
result: SchemaDescriptor::new("nav.Result", 1, "NavigationResult"),
}
}
fn make_pair() -> (
BasicActionClient<String, u32, String>,
BasicActionServer<String, u32, String>,
) {
let ch: Arc<ActionChannel<String, u32, String>> = ActionChannel::new();
let client = BasicActionClient::with_channel("nav", schema(), Arc::clone(&ch));
let server = BasicActionServer::with_channel("nav", schema(), ch);
(client, server)
}
#[test]
fn goal_status_terminal_states() {
assert!(GoalStatus::Succeeded.is_terminal());
assert!(GoalStatus::Failed.is_terminal());
assert!(GoalStatus::Canceled.is_terminal());
assert!(GoalStatus::Rejected.is_terminal());
assert!(!GoalStatus::Accepted.is_terminal());
assert!(!GoalStatus::Executing.is_terminal());
assert!(!GoalStatus::Canceling.is_terminal());
}
#[test]
fn action_name_and_schema_accessible() {
let (client, server) = make_pair();
assert_eq!(client.action_name(), "nav");
assert_eq!(server.action_name(), "nav");
assert_eq!(client.schema().goal.id.0, "nav.Goal");
}
#[test]
fn send_goal_returns_goal_id_and_ack() {
let (mut client, _server) = make_pair();
let (id, ack) = client.send_goal("go to kitchen".to_string()).unwrap();
assert!(ack.accepted);
assert!(ack.reason.is_none());
assert_eq!(id.0, 1);
}
#[test]
fn multiple_goals_unique_ids() {
let (mut client, _server) = make_pair();
let (id1, _) = client.send_goal("goal-A".to_string()).unwrap();
let (id2, _) = client.send_goal("goal-B".to_string()).unwrap();
let (id3, _) = client.send_goal("goal-C".to_string()).unwrap();
assert_ne!(id1, id2);
assert_ne!(id2, id3);
}
#[test]
fn server_receives_goal_from_client() {
let (mut client, mut server) = make_pair();
let (sent_id, _) = client.send_goal("patrol".to_string()).unwrap();
let received = server.recv_goal().unwrap();
assert!(received.is_some());
let (recv_id, goal) = received.unwrap();
assert_eq!(recv_id, sent_id);
assert_eq!(goal, "patrol");
}
#[test]
fn server_recv_goal_returns_none_when_empty() {
let (_client, mut server) = make_pair();
assert!(server.recv_goal().unwrap().is_none());
}
#[test]
fn server_accept_goal_transitions_status_to_executing() {
let (mut client, mut server) = make_pair();
let (id, _) = client.send_goal("dock".to_string()).unwrap();
let (recv_id, _) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_id).unwrap();
assert_eq!(client.goal_status(id), Some(GoalStatus::Executing));
}
#[test]
fn server_reject_goal_produces_rejected_result() {
let (mut client, mut server) = make_pair();
let (id, _) = client.send_goal("forbidden_zone".to_string()).unwrap();
let (recv_id, _) = server.recv_goal().unwrap().unwrap();
server.reject_goal(recv_id, "zone inaccessible").unwrap();
assert_eq!(client.goal_status(id), Some(GoalStatus::Rejected));
let result = client
.poll_result(id)
.expect("rejected goal must have result");
assert_eq!(result.status, GoalStatus::Rejected);
assert!(result.value.is_none());
}
#[test]
fn server_publishes_feedback_client_polls_in_order() {
let (mut client, mut server) = make_pair();
let (id, _) = client.send_goal("long_route".to_string()).unwrap();
let (recv_id, _) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_id).unwrap();
server.publish_feedback(recv_id, 10u32).unwrap();
server.publish_feedback(recv_id, 50u32).unwrap();
server.publish_feedback(recv_id, 90u32).unwrap();
assert_eq!(client.poll_feedback(id).map(|f| f.value), Some(10u32));
assert_eq!(client.poll_feedback(id).map(|f| f.value), Some(50u32));
assert_eq!(client.poll_feedback(id).map(|f| f.value), Some(90u32));
assert!(client.poll_feedback(id).is_none(), "no more feedback");
}
#[test]
fn feedback_from_different_goals_do_not_mix() {
let (mut client, mut server) = make_pair();
let (id_a, _) = client.send_goal("goal-A".to_string()).unwrap();
let (id_b, _) = client.send_goal("goal-B".to_string()).unwrap();
let (recv_a, _) = server.recv_goal().unwrap().unwrap();
let (recv_b, _) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_a).unwrap();
server.accept_goal(recv_b).unwrap();
server.publish_feedback(recv_a, 1u32).unwrap();
server.publish_feedback(recv_b, 99u32).unwrap();
assert_eq!(client.poll_feedback(id_a).map(|f| f.value), Some(1u32));
assert_eq!(client.poll_feedback(id_b).map(|f| f.value), Some(99u32));
assert!(client.poll_feedback(id_a).is_none());
assert!(client.poll_feedback(id_b).is_none());
}
#[test]
fn server_succeed_produces_result_with_value() {
let (mut client, mut server) = make_pair();
let (id, _) = client.send_goal("deliver".to_string()).unwrap();
let (recv_id, _) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_id).unwrap();
server
.succeed(recv_id, "package delivered".to_string())
.unwrap();
let result = client.poll_result(id).expect("must have result");
assert_eq!(result.status, GoalStatus::Succeeded);
assert_eq!(result.value.as_deref(), Some("package delivered"));
assert!(result.error.is_none());
assert_eq!(client.goal_status(id), Some(GoalStatus::Succeeded));
}
#[test]
fn server_fail_produces_result_with_error() {
let (mut client, mut server) = make_pair();
let (id, _) = client.send_goal("risky_op".to_string()).unwrap();
let (recv_id, _) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_id).unwrap();
server.fail(recv_id, "obstacle detected").unwrap();
let result = client.poll_result(id).expect("must have result");
assert_eq!(result.status, GoalStatus::Failed);
assert!(result.value.is_none());
assert_eq!(result.error.as_deref(), Some("obstacle detected"));
}
#[test]
fn client_cancel_request_polled_by_server() {
let (mut client, mut server) = make_pair();
let (id, _) = client.send_goal("long_task".to_string()).unwrap();
let (recv_id, _) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_id).unwrap();
client.cancel(id).unwrap();
let cancel_req = server.poll_cancel_request();
assert_eq!(cancel_req, Some(id));
}
#[test]
fn server_confirm_cancel_produces_canceled_result() {
let (mut client, mut server) = make_pair();
let (id, _) = client.send_goal("abortable".to_string()).unwrap();
let (recv_id, _) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_id).unwrap();
client.cancel(id).unwrap();
server.poll_cancel_request();
server.confirm_cancel(recv_id).unwrap();
let result = client.poll_result(id).expect("must have canceled result");
assert_eq!(result.status, GoalStatus::Canceled);
assert!(result.value.is_none());
assert_eq!(client.goal_status(id), Some(GoalStatus::Canceled));
}
#[test]
fn client_timeout_marks_goal_failed_when_heartbeat_missing() {
let (client, mut server) = make_pair();
let mut client = client.with_heartbeat_timeout(Some(std::time::Duration::from_millis(50)));
let (id, _) = client.send_goal("timeout-case".to_string()).unwrap();
let (recv_id, _) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_id).unwrap();
let future = Timestamp(Timestamp::now().0 + 2_000_000_000);
let transitioned = client.tick_timeouts(future);
assert_eq!(transitioned, 1);
assert_eq!(client.goal_status(id), Some(GoalStatus::Failed));
let result = client
.poll_result(id)
.expect("timeout should produce result");
assert_eq!(result.status, GoalStatus::Failed);
assert!(
result
.error
.as_deref()
.unwrap_or_default()
.contains("heartbeat timeout")
);
}
#[test]
fn server_heartbeat_extends_goal_liveness_window() {
let (client, mut server) = make_pair();
let mut client = client.with_heartbeat_timeout(Some(std::time::Duration::from_millis(100)));
let (id, _) = client.send_goal("heartbeat-case".to_string()).unwrap();
let (recv_id, _) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_id).unwrap();
server.heartbeat(recv_id).unwrap();
let near_future = Timestamp(Timestamp::now().0 + 30_000_000);
assert_eq!(client.tick_timeouts(near_future), 0);
assert_eq!(client.goal_status(id), Some(GoalStatus::Executing));
let far_future = Timestamp(Timestamp::now().0 + 2_000_000_000);
assert_eq!(client.tick_timeouts(far_future), 1);
assert_eq!(client.goal_status(id), Some(GoalStatus::Failed));
}
#[test]
fn closed_client_cannot_send_goal() {
let (mut client, _server) = make_pair();
client.close().unwrap();
assert!(client.send_goal("x".to_string()).is_err());
}
#[test]
fn closed_server_cannot_recv_goal() {
let (_client, mut server) = make_pair();
server.close().unwrap();
assert!(server.recv_goal().is_err());
}
#[test]
fn standalone_client_send_goal_always_accepted() {
let mut client: BasicActionClient<String, u32, String> =
BasicActionClient::new("standalone", schema());
let (id, ack) = client.send_goal("test".to_string()).unwrap();
assert!(ack.accepted);
assert_eq!(id.0, 1);
}
#[test]
fn standalone_server_inject_goal_recv() {
let mut server: BasicActionServer<String, u32, String> =
BasicActionServer::new("standalone", schema());
let goal_id = ActionGoalId::new(42);
server.inject_goal(goal_id, "injected".to_string());
let got = server.recv_goal().unwrap().unwrap();
assert_eq!(got.0, goal_id);
assert_eq!(got.1, "injected");
}
#[test]
fn full_action_lifecycle_goal_feedback_succeed() {
let (mut client, mut server) = make_pair();
let (goal_id, ack) = client.send_goal("full_nav".to_string()).unwrap();
assert!(ack.accepted);
let (recv_id, _goal) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_id).unwrap();
assert_eq!(client.goal_status(goal_id), Some(GoalStatus::Executing));
for pct in [25u32, 50, 75] {
server.publish_feedback(recv_id, pct).unwrap();
}
let fb_values: Vec<u32> = (0..3)
.map(|_| client.poll_feedback(goal_id).unwrap().value)
.collect();
assert_eq!(fb_values, [25, 50, 75]);
server.succeed(recv_id, "arrived".to_string()).unwrap();
let result = client.poll_result(goal_id).unwrap();
assert_eq!(result.status, GoalStatus::Succeeded);
assert_eq!(result.value.as_deref(), Some("arrived"));
assert!(client.poll_feedback(goal_id).is_none());
assert!(client.poll_result(goal_id).is_none());
}
#[test]
fn goal_health_reports_active_then_completed() {
let (mut client, mut server) = make_pair();
let (goal_id, _) = client.send_goal("health-check".to_string()).unwrap();
let (recv_id, _) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_id).unwrap();
server.publish_feedback(recv_id, 20).unwrap();
let health = client
.goal_health(goal_id)
.expect("goal health should exist");
assert_eq!(health.status, GoalStatus::Executing);
assert_eq!(health.liveness, ActionLiveness::Active);
assert!(health.last_feedback_at_unix_nanos.is_some());
server.succeed(recv_id, "done".to_string()).unwrap();
let completed = client
.goal_health(goal_id)
.expect("goal health should exist after success");
assert_eq!(completed.status, GoalStatus::Succeeded);
assert_eq!(completed.liveness, ActionLiveness::Completed);
assert!(completed.last_result_at_unix_nanos.is_some());
}
#[test]
fn goal_health_reports_stalled_and_timed_out() {
let (client, mut server) = make_pair();
let mut client = client
.with_stalled_threshold(Some(std::time::Duration::from_millis(10)))
.with_heartbeat_timeout(Some(std::time::Duration::from_millis(50)));
let (goal_id, _) = client.send_goal("stalled-check".to_string()).unwrap();
let (recv_id, _) = server.recv_goal().unwrap().unwrap();
server.accept_goal(recv_id).unwrap();
std::thread::sleep(std::time::Duration::from_millis(15));
let stalled = client
.goal_health(goal_id)
.expect("goal health should exist");
assert_eq!(stalled.liveness, ActionLiveness::Stalled);
let future = Timestamp(Timestamp::now().0 + 2_000_000_000);
let transitioned = client.tick_timeouts(future);
assert_eq!(transitioned, 1);
let timed_out = client
.goal_health(goal_id)
.expect("goal health should exist");
assert_eq!(timed_out.status, GoalStatus::Failed);
assert_eq!(timed_out.liveness, ActionLiveness::Completed);
}