mecha10_behavior_runtime/actions/
set_instance_track_target.rs1use crate::{BehaviorNode, Context, NodeStatus};
9use async_trait::async_trait;
10use mecha10_core::messages::Message;
11use mecha10_core::topics::Topic;
12use mecha10_core::Receiver;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
17pub(crate) struct BoundingBox {
18 x: f32,
19 y: f32,
20 width: f32,
21 height: f32,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25struct InstanceTrackControlCommand {
26 action: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 params: Option<Value>,
29}
30
31impl Message for InstanceTrackControlCommand {}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub(crate) struct InstanceTrackTargetRequest {
35 #[serde(default)]
36 pub(crate) bbox: Option<BoundingBox>,
37}
38
39impl Message for InstanceTrackTargetRequest {}
40
41fn default_topic() -> String {
42 "/instance-track/control".to_string()
43}
44
45pub(crate) fn default_request_topic() -> String {
49 "/instance-track/target/request".to_string()
50}
51
52pub struct SetInstanceTrackTargetNode {
53 topic: String,
54 request_topic: String,
55 target: Option<BoundingBox>,
56 receiver: Option<Receiver<Value>>,
57 published_initial: bool,
58}
59
60impl std::fmt::Debug for SetInstanceTrackTargetNode {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("SetInstanceTrackTargetNode")
63 .field("topic", &self.topic)
64 .field("request_topic", &self.request_topic)
65 .field("target", &self.target)
66 .field("subscribed", &self.receiver.is_some())
67 .field("published_initial", &self.published_initial)
68 .finish()
69 }
70}
71
72#[derive(Debug, Serialize, Deserialize)]
73struct SetInstanceTrackTargetConfig {
74 #[serde(default = "default_topic")]
75 topic: String,
76 #[serde(default = "default_request_topic")]
77 request_topic: String,
78 #[serde(default)]
79 bbox: Option<BoundingBox>,
80}
81
82impl SetInstanceTrackTargetNode {
83 pub fn from_json(config: Value) -> anyhow::Result<Self> {
86 let config = if config.is_null() {
87 serde_json::json!({})
88 } else {
89 config
90 };
91 let cfg: SetInstanceTrackTargetConfig = serde_json::from_value(config)?;
92 Ok(Self {
93 topic: cfg.topic,
94 request_topic: cfg.request_topic,
95 target: cfg.bbox,
96 receiver: None,
97 published_initial: false,
98 })
99 }
100
101 async fn publish_set_target(&self, ctx: &Context, bbox: BoundingBox) -> anyhow::Result<()> {
102 let command = InstanceTrackControlCommand {
103 action: "set_target".to_string(),
104 params: Some(serde_json::json!({ "bbox": bbox })),
105 };
106
107 let topic_static: &'static str = Box::leak(self.topic.clone().into_boxed_str());
108 let topic = Topic::<InstanceTrackControlCommand>::new(topic_static);
109
110 ctx.publish_to(topic, &command).await?;
111 tracing::debug!("SetInstanceTrackTargetNode: published set_target({:?})", bbox);
112 Ok(())
113 }
114
115 async fn publish_clear(&self, ctx: &Context) -> anyhow::Result<()> {
116 let command = InstanceTrackControlCommand {
117 action: "clear".to_string(),
118 params: None,
119 };
120
121 let topic_static: &'static str = Box::leak(self.topic.clone().into_boxed_str());
122 let topic = Topic::<InstanceTrackControlCommand>::new(topic_static);
123
124 ctx.publish_to(topic, &command).await?;
125 tracing::debug!("SetInstanceTrackTargetNode: published clear");
126 Ok(())
127 }
128
129 fn poll_latest_request(receiver: &mut Receiver<Value>) -> Option<Option<BoundingBox>> {
133 let mut latest = None;
134 while let Ok(value) = receiver.try_recv() {
135 match serde_json::from_value::<InstanceTrackTargetRequest>(value) {
136 Ok(request) => latest = Some(request.bbox),
137 Err(e) => tracing::warn!("SetInstanceTrackTargetNode: dropping malformed target request: {}", e),
138 }
139 }
140 latest
141 }
142}
143
144#[async_trait]
145impl BehaviorNode for SetInstanceTrackTargetNode {
146 async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
147 if self.receiver.is_none() {
148 self.receiver = Some(ctx.subscribe_raw::<Value>(&self.request_topic).await?);
149 }
150
151 if !self.published_initial {
152 self.published_initial = true;
153 if let Some(bbox) = self.target {
154 self.publish_set_target(ctx, bbox).await?;
155 }
156 }
157
158 let latest = Self::poll_latest_request(self.receiver.as_mut().expect("just subscribed above"));
159 if let Some(bbox) = latest {
160 self.target = bbox;
161 match bbox {
162 Some(bbox) => self.publish_set_target(ctx, bbox).await?,
163 None => self.publish_clear(ctx).await?,
164 }
165 }
166
167 Ok(if self.target.is_some() {
171 NodeStatus::Running
172 } else {
173 NodeStatus::Failure
174 })
175 }
176
177 fn name(&self) -> &str {
178 "SetInstanceTrackTargetNode"
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use crate::test_helpers::create_test_context;
186
187 fn sample_bbox() -> BoundingBox {
188 BoundingBox {
189 x: 0.1,
190 y: 0.2,
191 width: 0.3,
192 height: 0.4,
193 }
194 }
195
196 #[test]
197 fn from_json_reads_topic_request_topic_and_bbox() {
198 let node = SetInstanceTrackTargetNode::from_json(serde_json::json!({
199 "topic": "/instance-track/control",
200 "request_topic": "/instance-track/target/request",
201 "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 }
202 }))
203 .unwrap();
204
205 assert_eq!(node.topic, "/instance-track/control");
206 assert_eq!(node.request_topic, "/instance-track/target/request");
207 assert_eq!(node.target, Some(sample_bbox()));
208 }
209
210 #[test]
211 fn from_json_defaults_topics_and_allows_no_target() {
212 let node = SetInstanceTrackTargetNode::from_json(serde_json::json!({})).unwrap();
213
214 assert_eq!(node.topic, "/instance-track/control");
215 assert_eq!(node.request_topic, "/instance-track/target/request");
216 assert_eq!(node.target, None);
217 }
218
219 #[test]
220 fn from_json_accepts_null_the_same_as_an_empty_object() {
221 let node = SetInstanceTrackTargetNode::from_json(Value::Null).unwrap();
222
223 assert_eq!(node.topic, "/instance-track/control");
224 assert_eq!(node.request_topic, "/instance-track/target/request");
225 assert_eq!(node.target, None);
226 }
227
228 #[test]
229 fn poll_latest_request_returns_none_when_empty() {
230 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
231 let mut receiver = Receiver::from(rx);
232 assert_eq!(SetInstanceTrackTargetNode::poll_latest_request(&mut receiver), None);
233 }
234
235 #[test]
236 fn poll_latest_request_keeps_only_the_most_recent_value() {
237 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
238 let mut receiver = Receiver::from(rx);
239 tx.send(serde_json::json!({ "bbox": { "x": 0.0, "y": 0.0, "width": 0.1, "height": 0.1 } }))
240 .unwrap();
241 tx.send(serde_json::json!({ "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 } }))
242 .unwrap();
243
244 assert_eq!(
245 SetInstanceTrackTargetNode::poll_latest_request(&mut receiver),
246 Some(Some(sample_bbox()))
247 );
248 }
249
250 #[test]
251 fn poll_latest_request_accepts_a_null_bbox_as_a_clear_request() {
252 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
253 let mut receiver = Receiver::from(rx);
254 tx.send(serde_json::json!({ "bbox": null })).unwrap();
255
256 assert_eq!(
257 SetInstanceTrackTargetNode::poll_latest_request(&mut receiver),
258 Some(None)
259 );
260 }
261
262 #[test]
263 fn poll_latest_request_accepts_an_absent_bbox_key_as_a_clear_request() {
264 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
265 let mut receiver = Receiver::from(rx);
266 tx.send(serde_json::json!({})).unwrap();
267
268 assert_eq!(
269 SetInstanceTrackTargetNode::poll_latest_request(&mut receiver),
270 Some(None)
271 );
272 }
273
274 #[test]
275 fn poll_latest_request_drops_malformed_requests_and_keeps_polling() {
276 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
277 let mut receiver = Receiver::from(rx);
278 tx.send(serde_json::json!({ "bbox": "not-a-bbox" })).unwrap();
279 tx.send(serde_json::json!({ "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 } }))
280 .unwrap();
281
282 assert_eq!(
283 SetInstanceTrackTargetNode::poll_latest_request(&mut receiver),
284 Some(Some(sample_bbox()))
285 );
286 }
287
288 #[tokio::test]
289 #[ignore]
290 async fn tick_publishes_configured_target_once_then_stays_running() {
291 let ctx = create_test_context("test_set_instance_track_target").await;
292
293 let mut node = SetInstanceTrackTargetNode::from_json(serde_json::json!({
294 "topic": "/instance-track/control",
295 "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 }
296 }))
297 .unwrap();
298
299 let mut receiver = ctx.subscribe_raw::<Value>("/instance-track/control").await.unwrap();
300
301 let status = node.tick(&ctx).await.unwrap();
302 assert_eq!(status, NodeStatus::Running);
303
304 let received = tokio::time::timeout(std::time::Duration::from_millis(500), receiver.recv())
305 .await
306 .expect("timed out waiting for published command")
307 .expect("channel closed without a message");
308
309 assert_eq!(received["action"], "set_target");
310 assert_eq!(received["params"]["bbox"]["x"], 0.1);
311
312 let status = node.tick(&ctx).await.unwrap();
313 assert_eq!(status, NodeStatus::Running);
314 let timed_out = tokio::time::timeout(std::time::Duration::from_millis(200), receiver.recv()).await;
315 assert!(timed_out.is_err(), "expected no second publish without a new request");
316 }
317
318 #[tokio::test]
319 #[ignore]
320 async fn tick_relays_a_live_request_published_on_request_topic() {
321 let ctx = create_test_context("test_set_instance_track_target_request").await;
322
323 let mut node = SetInstanceTrackTargetNode::from_json(serde_json::json!({
324 "topic": "/instance-track/control",
325 "request_topic": "/instance-track/target/request"
326 }))
327 .unwrap();
328
329 node.tick(&ctx).await.unwrap();
330
331 let mut control_receiver = ctx.subscribe_raw::<Value>("/instance-track/control").await.unwrap();
332 ctx.publish_raw(
333 "/instance-track/target/request",
334 &serde_json::json!({ "bbox": { "x": 0.5, "y": 0.5, "width": 0.1, "height": 0.1 } }),
335 )
336 .await
337 .unwrap();
338
339 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
340 node.tick(&ctx).await.unwrap();
341
342 let received = tokio::time::timeout(std::time::Duration::from_millis(500), control_receiver.recv())
343 .await
344 .expect("timed out waiting for relayed command")
345 .expect("channel closed without a message");
346
347 assert_eq!(received["action"], "set_target");
348 assert_eq!(received["params"]["bbox"]["x"], 0.5);
349 assert_eq!(
350 node.tick(&ctx).await.unwrap(),
351 NodeStatus::Running,
352 "a live target must report Running"
353 );
354 }
355
356 #[tokio::test]
357 #[ignore = "Requires Redis infrastructure"]
358 async fn tick_returns_failure_when_no_target_has_ever_been_set() {
359 let ctx = create_test_context("test_set_instance_track_target_idle").await;
360
361 let mut node = SetInstanceTrackTargetNode::from_json(serde_json::json!({
362 "request_topic": "/instance-track/target/request"
363 }))
364 .unwrap();
365
366 let status = node.tick(&ctx).await.unwrap();
367 assert_eq!(
368 status,
369 NodeStatus::Failure,
370 "no target yet means cede control, not Success"
371 );
372 }
373
374 #[tokio::test]
375 #[ignore = "Requires Redis infrastructure"]
376 async fn tick_returns_failure_after_a_clear_request_even_with_a_configured_initial_target() {
377 let ctx = create_test_context("test_set_instance_track_target_cleared").await;
378
379 let mut node = SetInstanceTrackTargetNode::from_json(serde_json::json!({
380 "request_topic": "/instance-track/target/request",
381 "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 }
382 }))
383 .unwrap();
384
385 assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Running);
386
387 ctx.publish_raw("/instance-track/target/request", &serde_json::json!({ "bbox": null }))
388 .await
389 .unwrap();
390 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
391
392 assert_eq!(node.tick(&ctx).await.unwrap(), NodeStatus::Failure);
393 }
394
395 #[tokio::test]
396 #[ignore]
397 async fn tick_relays_a_clear_request_published_on_request_topic() {
398 let ctx = create_test_context("test_set_instance_track_target_clear").await;
399
400 let mut node = SetInstanceTrackTargetNode::from_json(serde_json::json!({
401 "topic": "/instance-track/control",
402 "request_topic": "/instance-track/target/request"
403 }))
404 .unwrap();
405
406 node.tick(&ctx).await.unwrap();
407
408 let mut control_receiver = ctx.subscribe_raw::<Value>("/instance-track/control").await.unwrap();
409 ctx.publish_raw("/instance-track/target/request", &serde_json::json!({ "bbox": null }))
410 .await
411 .unwrap();
412
413 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
414 node.tick(&ctx).await.unwrap();
415
416 let received = tokio::time::timeout(std::time::Duration::from_millis(500), control_receiver.recv())
417 .await
418 .expect("timed out waiting for relayed command")
419 .expect("channel closed without a message");
420
421 assert_eq!(received["action"], "clear");
422 }
423}