robotrt-action-core 0.1.0-beta.2

RobotRT modular robotics runtime and middleware components.
Documentation
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;

// ─── Test helpers ─────────────────────────────────────────────────────────────

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"),
    }
}

/// Create a connected client/server pair sharing the same [`ActionChannel`].
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)
}

// ─── GoalStatus terminal-state checks ────────────────────────────────────────

#[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());
}

// ─── Basic goal round-trip ────────────────────────────────────────────────────

#[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());
    // Goal IDs are monotonically increasing, starting at 1.
    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);
}

// ─── server.recv_goal / accept / reject ──────────────────────────────────────

#[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());
}

// ─── Feedback stream ──────────────────────────────────────────────────────────

#[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();

    // Drain both from server
    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));
    // Cross-check: no leakage
    assert!(client.poll_feedback(id_a).is_none());
    assert!(client.poll_feedback(id_b).is_none());
}

// ─── Succeed / Fail ───────────────────────────────────────────────────────────

#[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());
    // Status transitions to Succeeded
    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"));
}

// ─── Cancel flow ─────────────────────────────────────────────────────────────

#[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));
}

// ─── Close semantics ──────────────────────────────────────────────────────────

#[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());
}

// ─── Standalone mode (no channel) ────────────────────────────────────────────

#[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");
}

// ─── Full goal lifecycle (Goal → Feedback × 3 → Succeed) ─────────────────────

#[test]
fn full_action_lifecycle_goal_feedback_succeed() {
    let (mut client, mut server) = make_pair();

    // 1. Client sends a goal.
    let (goal_id, ack) = client.send_goal("full_nav".to_string()).unwrap();
    assert!(ack.accepted);

    // 2. Server receives and accepts the goal.
    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));

    // 3. Server publishes periodic feedback.
    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]);

    // 4. Goal completes; server sends the final result.
    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"));

    // 5. No further feedback or result.
    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);
}