1use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
14use async_trait::async_trait;
15use mecha10_core::actuator::Twist;
16use mecha10_core::config::{controller_mode, generate_joint_command, joint_limits, ControllerMode};
17use mecha10_core::messages::JointState;
18use mecha10_core::topics::actuator as actuator_topics;
19use mecha10_core::topics::sensor as sensor_topics;
20use mecha10_core::Receiver;
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23use std::time::{Duration, Instant};
24
25use super::elapsed_timer::{reset_timer_field, ElapsedTimer};
26
27fn default_motor_topic() -> String {
31 "/motor/cmd_vel".to_string()
32}
33
34#[derive(Debug)]
37enum MovementOutput {
38 Twist { topic: String, twist: Twist },
41 Joints { topic: String, joint_state: JointState },
43}
44
45fn resolve_movement_output(configured_topic: &str, linear: f64, angular: f64) -> MovementOutput {
63 match controller_mode() {
64 ControllerMode::Locomotion => MovementOutput::Twist {
65 topic: configured_topic.to_string(),
66 twist: Twist {
67 linear: linear as f32,
68 angular: angular as f32,
69 },
70 },
71 ControllerMode::Joints { joint_names } => MovementOutput::Joints {
72 topic: joint_command_topic(configured_topic),
73 joint_state: joint_command(&joint_names, linear as f32, angular as f32),
74 },
75 }
76}
77
78fn joint_command_topic(configured_topic: &str) -> String {
84 if configured_topic == default_motor_topic() {
85 actuator_topics::JOINT_CMD.path().to_string()
86 } else {
87 configured_topic.to_string()
88 }
89}
90
91fn joint_command(joint_names: &[String], linear: f32, angular: f32) -> JointState {
97 let mut names = Vec::new();
98 let mut positions = Vec::new();
99
100 if let Some(name) = joint_names.first() {
101 let cmd = generate_joint_command(name, linear);
102 names.extend(cmd.names);
103 positions.extend(cmd.positions);
104 }
105 if let Some(name) = joint_names.get(1) {
106 let cmd = generate_joint_command(name, angular);
107 names.extend(cmd.names);
108 positions.extend(cmd.positions);
109 }
110
111 JointState::with_positions(names, positions)
112}
113
114async fn publish_movement(ctx: &Context, configured_topic: &str, linear: f64, angular: f64) -> anyhow::Result<()> {
117 match resolve_movement_output(configured_topic, linear, angular) {
118 MovementOutput::Twist { topic, twist } => {
119 ctx.publish_to_path(&topic, &twist).await?;
120 }
121 MovementOutput::Joints { topic, joint_state } => {
122 ctx.publish_to_path(&topic, &joint_state).await?;
123 }
124 }
125 Ok(())
126}
127
128#[derive(Debug)]
129pub struct WanderNode {
130 topic: String,
131 linear_speed: f64,
132 angular_speed: f64,
133 change_interval: Duration,
134 last_change: Option<Instant>,
135 current_angular: f64,
136 last_status: Option<NodeStatus>,
137}
138
139#[derive(Debug, Serialize, Deserialize)]
140struct WanderConfig {
141 #[serde(default = "default_motor_topic")]
142 topic: String,
143 #[serde(default = "default_linear_speed")]
144 linear_speed: f64,
145 #[serde(default = "default_angular_speed")]
146 angular_speed: f64,
147 #[serde(default = "default_change_interval")]
148 change_interval_secs: f64,
149}
150
151fn default_linear_speed() -> f64 {
152 0.3
153}
154
155fn default_angular_speed() -> f64 {
156 0.5
157}
158
159fn default_change_interval() -> f64 {
160 3.0
161}
162
163impl WanderNode {
164 pub fn from_json(config: Value) -> anyhow::Result<Self> {
166 let cfg: WanderConfig = serde_json::from_value(config)?;
167 Ok(Self {
168 topic: cfg.topic,
169 linear_speed: cfg.linear_speed,
170 angular_speed: cfg.angular_speed,
171 change_interval: Duration::from_secs_f64(cfg.change_interval_secs),
172 last_change: None,
173 current_angular: 0.0,
174 last_status: None,
175 })
176 }
177}
178
179#[async_trait]
180impl BehaviorNode for WanderNode {
181 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
182 let now = Instant::now();
183
184 if self.last_change.is_none() || now.duration_since(self.last_change.unwrap()) >= self.change_interval {
185 self.current_angular = (rand::random::<f64>() * 2.0 - 1.0) * self.angular_speed;
186 self.last_change = Some(now);
187 }
188
189 publish_movement(ctx, &self.topic, self.linear_speed, self.current_angular).await?;
190 self.last_status = Some(NodeStatus::Running);
191 Ok(NodeStatus::Running)
192 }
193
194 fn name(&self) -> &str {
195 "WanderNode"
196 }
197
198 fn status_snapshot(&self) -> TreeStatusSnapshot {
199 TreeStatusSnapshot {
200 name: self.name().to_string(),
201 status: self.last_status,
202 active_capability: self.active_capability().map(String::from),
203 children: Vec::new(),
204 }
205 }
206}
207
208const JOINT_POSITION_TOLERANCE_RAD: f32 = 0.01;
212
213#[derive(Default)]
214pub struct MoveNode {
215 topic: String,
216 linear: f64,
217 angular: f64,
218 duration: Option<Duration>,
219 speed: Option<f64>,
224 timer: ElapsedTimer,
225 joint_feedback: Option<Receiver<JointState>>,
228 latest_joint_state: Option<JointState>,
229 last_step_tick: Option<Instant>,
232 last_status: Option<NodeStatus>,
233}
234
235impl std::fmt::Debug for MoveNode {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 f.debug_struct("MoveNode")
238 .field("topic", &self.topic)
239 .field("linear", &self.linear)
240 .field("angular", &self.angular)
241 .field("duration", &self.duration)
242 .field("speed", &self.speed)
243 .field("timer", &self.timer)
244 .field("joint_feedback_subscribed", &self.joint_feedback.is_some())
245 .field("latest_joint_state", &self.latest_joint_state)
246 .field("last_step_tick", &self.last_step_tick)
247 .field("last_status", &self.last_status)
248 .finish()
249 }
250}
251
252#[derive(Debug, Serialize, Deserialize)]
253struct MoveConfig {
254 #[serde(default = "default_motor_topic")]
255 topic: String,
256 linear: f64,
257 angular: f64,
258 duration_secs: Option<f64>,
259 speed: Option<f64>,
260}
261
262impl MoveNode {
263 pub fn from_json(config: Value) -> anyhow::Result<Self> {
265 let cfg: MoveConfig = serde_json::from_value(config)?;
266 Ok(Self {
267 topic: cfg.topic,
268 linear: cfg.linear,
269 angular: cfg.angular,
270 duration: cfg.duration_secs.map(Duration::from_secs_f64),
271 speed: cfg.speed,
272 timer: ElapsedTimer::default(),
273 joint_feedback: None,
274 latest_joint_state: None,
275 last_step_tick: None,
276 last_status: None,
277 })
278 }
279
280 async fn tick_speed_limited(
287 &mut self,
288 ctx: &Context,
289 joint_names: &[String],
290 speed: f64,
291 ) -> anyhow::Result<NodeStatus> {
292 if self.joint_feedback.is_none() {
293 self.joint_feedback = Some(ctx.subscribe(sensor_topics::JOINT_STATES).await?);
294 }
295 while let Ok(state) = self.joint_feedback.as_mut().expect("just subscribed above").try_recv() {
296 self.latest_joint_state = Some(state);
297 }
298
299 let Some(latest) = self.latest_joint_state.as_ref() else {
300 tracing::debug!(
306 "MoveNode: waiting for first {} reading before speed-limited joint stepping",
307 sensor_topics::JOINT_STATES.path()
308 );
309 self.last_status = Some(NodeStatus::Running);
310 return Ok(NodeStatus::Running);
311 };
312
313 let now = Instant::now();
314 let dt = self
315 .last_step_tick
316 .map(|last| now.duration_since(last).as_secs_f64())
317 .unwrap_or(0.0);
318 self.last_step_tick = Some(now);
319 let max_step = (speed * dt) as f32;
320
321 let mut names = Vec::new();
322 let mut positions = Vec::new();
323 let mut all_arrived = true;
324
325 for (name, raw_target) in [
326 (joint_names.first(), self.linear as f32),
327 (joint_names.get(1), self.angular as f32),
328 ] {
329 let Some(name) = name else { continue };
330 let Some(current) = latest.get_position(name) else {
331 tracing::debug!(joint = %name, "MoveNode: waiting for feedback on this joint before speed-limited stepping");
334 self.last_status = Some(NodeStatus::Running);
335 return Ok(NodeStatus::Running);
336 };
337 let target = clamped_joint_target(name, raw_target);
338 let (stepped, arrived) = step_joint_position(current, target, max_step, JOINT_POSITION_TOLERANCE_RAD);
339 names.push(name.clone());
340 positions.push(stepped);
341 all_arrived &= arrived;
342 }
343
344 ctx.publish_to_path(
345 &joint_command_topic(&self.topic),
346 &JointState::with_positions(names.clone(), positions),
347 )
348 .await?;
349
350 if !names.is_empty() && all_arrived {
351 self.last_status = Some(NodeStatus::Success);
352 return Ok(NodeStatus::Success);
353 }
354
355 self.last_status = Some(NodeStatus::Running);
356 Ok(NodeStatus::Running)
357 }
358}
359
360#[async_trait]
361impl BehaviorNode for MoveNode {
362 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
363 if let Some(duration) = self.duration {
366 if self.timer.elapsed_since_start(duration) {
367 self.last_status = Some(NodeStatus::Success);
368 return Ok(NodeStatus::Success);
369 }
370 }
371
372 let mode = controller_mode();
373 if wants_speed_limited_stepping(self.speed, &mode) {
374 let ControllerMode::Joints { joint_names } = mode else {
375 unreachable!("wants_speed_limited_stepping only returns true for ControllerMode::Joints");
376 };
377 let speed = self
378 .speed
379 .expect("wants_speed_limited_stepping only returns true when speed is set");
380 return self.tick_speed_limited(ctx, &joint_names, speed).await;
381 }
382
383 publish_movement(ctx, &self.topic, self.linear, self.angular).await?;
384
385 self.last_status = Some(NodeStatus::Running);
386 Ok(NodeStatus::Running)
387 }
388
389 reset_timer_field!(timer);
390
391 fn name(&self) -> &str {
392 "MoveNode"
393 }
394
395 fn status_snapshot(&self) -> TreeStatusSnapshot {
396 TreeStatusSnapshot {
397 name: self.name().to_string(),
398 status: self.last_status,
399 active_capability: self.active_capability().map(String::from),
400 children: Vec::new(),
401 }
402 }
403}
404
405fn wants_speed_limited_stepping(speed: Option<f64>, mode: &ControllerMode) -> bool {
411 speed.is_some() && matches!(mode, ControllerMode::Joints { .. })
412}
413
414fn clamped_joint_target(joint_name: &str, raw_target: f32) -> f32 {
419 match joint_limits(joint_name) {
420 Some(limits) => raw_target.clamp(limits.min_angle_rad, limits.max_angle_rad),
421 None => raw_target,
422 }
423}
424
425fn step_joint_position(current: f32, target: f32, max_step: f32, tolerance: f32) -> (f32, bool) {
429 let max_step = max_step.abs();
430 let delta = target - current;
431 let stepped = current + delta.clamp(-max_step, max_step);
432 let arrived = (target - stepped).abs() <= tolerance;
433 (stepped, arrived)
434}
435
436#[derive(Debug)]
437pub struct StopNode {
438 topic: String,
439 last_status: Option<NodeStatus>,
440}
441
442#[derive(Debug, Serialize, Deserialize)]
443struct StopConfig {
444 #[serde(default = "default_motor_topic")]
445 topic: String,
446}
447
448impl StopNode {
449 pub fn from_json(config: Value) -> anyhow::Result<Self> {
451 let cfg: StopConfig = serde_json::from_value(config)?;
452 Ok(Self {
453 topic: cfg.topic,
454 last_status: None,
455 })
456 }
457}
458
459#[async_trait]
460impl BehaviorNode for StopNode {
461 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
462 publish_movement(ctx, &self.topic, 0.0, 0.0).await?;
463 tracing::debug!("StopNode: published stop command");
464 self.last_status = Some(NodeStatus::Success);
465 Ok(NodeStatus::Success)
466 }
467
468 fn name(&self) -> &str {
469 "StopNode"
470 }
471
472 fn status_snapshot(&self) -> TreeStatusSnapshot {
473 TreeStatusSnapshot {
474 name: self.name().to_string(),
475 status: self.last_status,
476 active_capability: self.active_capability().map(String::from),
477 children: Vec::new(),
478 }
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use mecha10_core::config::{ACTUATION_JOINT_NAMES_ENV_VAR, ACTUATOR_JOINT_LIMITS_ENV_VAR, CAPABILITIES_ENV_VAR};
486 use std::env;
487 use std::sync::Mutex;
488
489 static CONTROLLER_ENV_LOCK: Mutex<()> = Mutex::new(());
493
494 fn clear_controller_env() {
495 env::remove_var(CAPABILITIES_ENV_VAR);
496 env::remove_var(ACTUATION_JOINT_NAMES_ENV_VAR);
497 env::remove_var(ACTUATOR_JOINT_LIMITS_ENV_VAR);
498 }
499
500 #[test]
501 fn resolve_movement_output_is_a_twist_to_the_configured_topic_in_locomotion_mode() {
502 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
503 clear_controller_env();
504
505 let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
506
507 let MovementOutput::Twist { topic, twist } = output else {
508 panic!("expected Twist output");
509 };
510 assert_eq!(topic, "/motor/cmd_vel");
511 assert_eq!(twist.linear, 1.0);
512 assert_eq!(twist.angular, 0.5);
513
514 clear_controller_env();
515 }
516
517 #[test]
518 fn resolve_movement_output_ignores_declared_joints_when_locomotion_also_declared() {
519 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
520 clear_controller_env();
521 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints","locomotion"]"#);
522 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
523
524 let output = resolve_movement_output("/motor/cmd_vel", 1.0, 0.5);
525
526 let MovementOutput::Twist { topic, twist } = output else {
527 panic!("expected Twist output");
528 };
529 assert_eq!(topic, "/motor/cmd_vel");
530 assert_eq!(twist.linear, 1.0);
531 assert_eq!(twist.angular, 0.5);
532
533 clear_controller_env();
534 }
535
536 #[test]
537 fn resolve_movement_output_is_a_joint_state_to_the_default_joint_topic_in_joints_mode() {
538 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
539 clear_controller_env();
540 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
541 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
542
543 let output = resolve_movement_output(&default_motor_topic(), 0.3, -0.2);
546
547 let MovementOutput::Joints { topic, joint_state } = output else {
548 panic!("expected Joints output");
549 };
550 assert_eq!(topic, actuator_topics::JOINT_CMD.path());
551 assert_eq!(joint_state.names, vec!["pan".to_string(), "tilt".to_string()]);
552 assert_eq!(joint_state.positions, vec![0.3, -0.2]);
553
554 clear_controller_env();
555 }
556
557 #[test]
558 fn resolve_movement_output_keeps_an_explicitly_overridden_topic_in_joints_mode() {
559 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
560 clear_controller_env();
561 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
562 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan","tilt"]"#);
563
564 let output = resolve_movement_output("/custom/joint_topic", 0.3, -0.2);
565
566 let MovementOutput::Joints { topic, .. } = output else {
567 panic!("expected Joints output");
568 };
569 assert_eq!(topic, "/custom/joint_topic");
570
571 clear_controller_env();
572 }
573
574 #[test]
575 fn resolve_movement_output_clamps_to_configured_joint_limits_in_joints_mode() {
576 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
577 clear_controller_env();
578 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
579 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
580 env::set_var(
581 ACTUATOR_JOINT_LIMITS_ENV_VAR,
582 r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
583 );
584
585 let output = resolve_movement_output(&default_motor_topic(), 5.0, 0.0);
586
587 let MovementOutput::Joints { joint_state, .. } = output else {
588 panic!("expected Joints output");
589 };
590 assert_eq!(joint_state.names, vec!["pan".to_string()]);
591 assert_eq!(joint_state.positions, vec![1.0]);
592
593 clear_controller_env();
594 }
595
596 #[test]
597 fn resolve_movement_output_drives_only_the_available_joint_when_fewer_than_two_are_configured() {
598 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
599 clear_controller_env();
600 env::set_var(CAPABILITIES_ENV_VAR, r#"["joints"]"#);
601 env::set_var(ACTUATION_JOINT_NAMES_ENV_VAR, r#"["pan"]"#);
602
603 let output = resolve_movement_output(&default_motor_topic(), 0.7, -0.4);
604
605 let MovementOutput::Joints { joint_state, .. } = output else {
606 panic!("expected Joints output");
607 };
608 assert_eq!(joint_state.names, vec!["pan".to_string()]);
609 assert_eq!(joint_state.positions, vec![0.7]);
610
611 clear_controller_env();
612 }
613
614 #[test]
615 fn wander_node_from_json_defaults_topic_to_the_locomotion_default() {
616 let node = WanderNode::from_json(serde_json::json!({})).unwrap();
617 assert_eq!(node.topic, default_motor_topic());
618 }
619
620 #[test]
621 fn move_node_from_json_defaults_topic_to_the_locomotion_default() {
622 let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
623 assert_eq!(node.topic, default_motor_topic());
624 }
625
626 #[test]
627 fn move_node_from_json_defaults_speed_to_unset() {
628 let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0 })).unwrap();
629 assert_eq!(node.speed, None);
630 }
631
632 #[test]
633 fn move_node_from_json_reads_speed_when_set() {
634 let node = MoveNode::from_json(serde_json::json!({ "linear": 1.0, "angular": 0.0, "speed": 0.5 })).unwrap();
635 assert_eq!(node.speed, Some(0.5));
636 }
637
638 #[test]
639 fn stop_node_from_json_defaults_topic_to_the_locomotion_default() {
640 let node = StopNode::from_json(serde_json::json!({})).unwrap();
641 assert_eq!(node.topic, default_motor_topic());
642 }
643
644 #[test]
653 fn wants_speed_limited_stepping_is_false_when_speed_is_unset_regardless_of_mode() {
654 assert!(!wants_speed_limited_stepping(None, &ControllerMode::Locomotion));
658 assert!(!wants_speed_limited_stepping(
659 None,
660 &ControllerMode::Joints {
661 joint_names: vec!["pan".to_string()]
662 }
663 ));
664 }
665
666 #[test]
667 fn wants_speed_limited_stepping_is_false_in_locomotion_mode_even_with_speed_set() {
668 assert!(!wants_speed_limited_stepping(Some(0.5), &ControllerMode::Locomotion));
671 }
672
673 #[test]
674 fn wants_speed_limited_stepping_is_true_in_joints_mode_with_speed_set() {
675 assert!(wants_speed_limited_stepping(
676 Some(0.5),
677 &ControllerMode::Joints {
678 joint_names: vec!["pan".to_string()]
679 }
680 ));
681 }
682
683 #[test]
684 fn step_joint_position_moves_toward_target_by_at_most_max_step() {
685 let (stepped, arrived) = step_joint_position(0.0, 1.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
688 assert!((stepped - 0.1).abs() < f32::EPSILON);
689 assert!(!arrived);
690 }
691
692 #[test]
693 fn step_joint_position_steps_toward_target_from_either_side() {
694 let (stepped, _) = step_joint_position(1.0, 0.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
695 assert!((stepped - 0.9).abs() < f32::EPSILON);
696 }
697
698 #[test]
699 fn step_joint_position_never_overshoots_the_target() {
700 let (stepped, arrived) = step_joint_position(0.95, 1.0, 10.0, JOINT_POSITION_TOLERANCE_RAD);
703 assert_eq!(stepped, 1.0);
704 assert!(arrived);
705 }
706
707 #[test]
708 fn step_joint_position_never_overshoots_the_target_stepping_downward() {
709 let (stepped, arrived) = step_joint_position(-0.95, -1.0, 10.0, JOINT_POSITION_TOLERANCE_RAD);
710 assert_eq!(stepped, -1.0);
711 assert!(arrived);
712 }
713
714 #[test]
715 fn step_joint_position_reports_arrived_once_within_tolerance() {
716 let (_, arrived) = step_joint_position(0.995, 1.0, 0.1, JOINT_POSITION_TOLERANCE_RAD);
719 assert!(arrived);
720
721 let (_, not_yet_arrived) = step_joint_position(0.9, 1.0, 0.0, JOINT_POSITION_TOLERANCE_RAD);
722 assert!(!not_yet_arrived);
723 }
724
725 #[test]
726 fn step_joint_position_treats_max_step_as_a_magnitude() {
727 let (stepped, _) = step_joint_position(0.0, 1.0, -0.1, JOINT_POSITION_TOLERANCE_RAD);
731 assert!((stepped - 0.1).abs() < f32::EPSILON);
732 }
733
734 #[test]
735 fn clamped_joint_target_passes_through_when_no_joint_limit_configured() {
736 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
737 clear_controller_env();
738
739 assert_eq!(clamped_joint_target("pan", 5.0), 5.0);
740
741 clear_controller_env();
742 }
743
744 #[test]
745 fn clamped_joint_target_clamps_to_configured_joint_limits() {
746 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
747 clear_controller_env();
748 env::set_var(
749 ACTUATOR_JOINT_LIMITS_ENV_VAR,
750 r#"[{"joint_id":"pan","min_angle_rad":-1.0,"max_angle_rad":1.0}]"#,
751 );
752
753 assert_eq!(clamped_joint_target("pan", 1000.0), 1.0);
754 assert_eq!(clamped_joint_target("pan", -1000.0), -1.0);
755
756 clear_controller_env();
757 }
758
759 #[tokio::test]
760 #[ignore = "Requires Redis infrastructure"]
761 async fn move_node_without_speed_publishes_the_static_target_and_succeeds_on_duration() {
762 {
773 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
774 clear_controller_env();
775 }
776
777 let mut node = MoveNode::from_json(serde_json::json!({
778 "linear": 1.0,
779 "angular": 0.5,
780 "duration_secs": 0.05
781 }))
782 .unwrap();
783
784 let ctx = crate::test_helpers::create_test_context("move_node_without_speed").await;
785 assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
786
787 tokio::time::sleep(Duration::from_millis(80)).await;
788 assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Success);
789
790 let _guard = CONTROLLER_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
791 clear_controller_env();
792 }
793}