mecha10-behavior-runtime 0.7.0

Behavior tree runtime for Mecha10 - unified AI and logic composition system
Documentation
//! Action nodes that inspect sensor state.

use crate::{BehaviorNode, Context, NodeStatus};
use async_trait::async_trait;
use mecha10_core::Receiver;
use serde::{Deserialize, Serialize};
use serde_json::Value;

pub struct SensorCheckNode {
    topic: String,
    field: String,
    operator: ComparisonOperator,
    threshold: f64,
    receiver: Option<Receiver<Value>>,
    latest: Option<Value>,
}

impl std::fmt::Debug for SensorCheckNode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("SensorCheckNode")
            .field("topic", &self.topic)
            .field("field", &self.field)
            .field("operator", &self.operator)
            .field("threshold", &self.threshold)
            .field("subscribed", &self.receiver.is_some())
            .field("latest", &self.latest)
            .finish()
    }
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ComparisonOperator {
    LessThan,
    GreaterThan,
    Equal,
}

impl ComparisonOperator {
    /// Evaluate `actual <op> threshold`.
    fn evaluate(&self, actual: f64, threshold: f64) -> bool {
        match self {
            ComparisonOperator::LessThan => actual < threshold,
            ComparisonOperator::GreaterThan => actual > threshold,
            ComparisonOperator::Equal => (actual - threshold).abs() < f64::EPSILON,
        }
    }
}

#[derive(Debug, Serialize, Deserialize)]
struct SensorCheckConfig {
    topic: String,
    field: String,
    operator: ComparisonOperator,
    threshold: f64,
}

impl SensorCheckNode {
    /// Create a new SensorCheckNode from JSON configuration
    pub fn from_json(config: Value) -> anyhow::Result<Self> {
        let cfg: SensorCheckConfig = serde_json::from_value(config)?;
        Ok(Self {
            topic: cfg.topic,
            field: cfg.field,
            operator: cfg.operator,
            threshold: cfg.threshold,
            receiver: None,
            latest: None,
        })
    }

    /// Drain any pending sensor messages (keeping only the most recent) and evaluate the
    /// configured condition against the latest known reading.
    ///
    /// Pure with respect to messaging infrastructure - it only touches the already-subscribed
    /// `Receiver`, which makes it directly unit-testable without a live `Context`/Redis.
    fn evaluate(&mut self) -> NodeStatus {
        if let Some(receiver) = self.receiver.as_mut() {
            while let Ok(msg) = receiver.try_recv() {
                self.latest = Some(msg);
            }
        }

        let Some(latest) = self.latest.as_ref() else {
            tracing::debug!(topic = %self.topic, "SensorCheckNode: waiting for first sensor reading");
            return NodeStatus::Running;
        };

        let Some(actual) = latest.get(&self.field).and_then(Value::as_f64) else {
            tracing::warn!(
                topic = %self.topic,
                field = %self.field,
                message = %latest,
                "SensorCheckNode: field missing or not numeric in sensor message"
            );
            return NodeStatus::Failure;
        };

        let met = self.operator.evaluate(actual, self.threshold);
        tracing::debug!(
            "SensorCheckNode: checking {} {} {} -> {}",
            actual,
            match self.operator {
                ComparisonOperator::LessThan => "<",
                ComparisonOperator::GreaterThan => ">",
                ComparisonOperator::Equal => "==",
            },
            self.threshold,
            met
        );

        if met {
            NodeStatus::Success
        } else {
            NodeStatus::Failure
        }
    }

    /// Construct a node with an already-connected receiver, bypassing live subscription.
    ///
    /// Used by unit tests to exercise `evaluate()` without requiring a real `Context`/Redis.
    #[cfg(test)]
    fn with_receiver(field: &str, operator: ComparisonOperator, threshold: f64, receiver: Receiver<Value>) -> Self {
        Self {
            topic: "test/topic".to_string(),
            field: field.to_string(),
            operator,
            threshold,
            receiver: Some(receiver),
            latest: None,
        }
    }
}

#[async_trait]
impl BehaviorNode for SensorCheckNode {
    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
        if self.receiver.is_none() {
            self.receiver = Some(ctx.subscribe_raw::<Value>(&self.topic).await?);
        }
        Ok(self.evaluate())
    }

    fn name(&self) -> &str {
        "SensorCheckNode"
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use tokio::sync::mpsc;

    fn node_with_channel(
        field: &str,
        operator: ComparisonOperator,
        threshold: f64,
    ) -> (SensorCheckNode, mpsc::UnboundedSender<Value>) {
        let (tx, rx) = mpsc::unbounded_channel();
        let node = SensorCheckNode::with_receiver(field, operator, threshold, Receiver::from(rx));
        (node, tx)
    }

    #[test]
    fn no_message_received_returns_running() {
        let (mut node, _tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
        assert_eq!(node.evaluate(), NodeStatus::Running);
    }

    #[test]
    fn missing_field_returns_failure() {
        let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
        tx.send(json!({ "other_field": 1.0 })).unwrap();
        assert_eq!(node.evaluate(), NodeStatus::Failure);
    }

    #[test]
    fn non_numeric_field_returns_failure() {
        let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
        tx.send(json!({ "distance": "close" })).unwrap();
        assert_eq!(node.evaluate(), NodeStatus::Failure);
    }

    #[test]
    fn less_than_success_when_below_threshold() {
        let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
        tx.send(json!({ "distance": 0.2 })).unwrap();
        assert_eq!(node.evaluate(), NodeStatus::Success);
    }

    #[test]
    fn less_than_failure_when_at_or_above_threshold() {
        let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);
        tx.send(json!({ "distance": 0.5 })).unwrap();
        assert_eq!(node.evaluate(), NodeStatus::Failure);

        tx.send(json!({ "distance": 1.0 })).unwrap();
        assert_eq!(node.evaluate(), NodeStatus::Failure);
    }

    #[test]
    fn greater_than_success_when_above_threshold() {
        let (mut node, tx) = node_with_channel("battery", ComparisonOperator::GreaterThan, 20.0);
        tx.send(json!({ "battery": 55.0 })).unwrap();
        assert_eq!(node.evaluate(), NodeStatus::Success);
    }

    #[test]
    fn greater_than_failure_when_at_or_below_threshold() {
        let (mut node, tx) = node_with_channel("battery", ComparisonOperator::GreaterThan, 20.0);
        tx.send(json!({ "battery": 20.0 })).unwrap();
        assert_eq!(node.evaluate(), NodeStatus::Failure);

        tx.send(json!({ "battery": 5.0 })).unwrap();
        assert_eq!(node.evaluate(), NodeStatus::Failure);
    }

    #[test]
    fn equal_success_when_matching() {
        let (mut node, tx) = node_with_channel("state", ComparisonOperator::Equal, 1.0);
        tx.send(json!({ "state": 1.0 })).unwrap();
        assert_eq!(node.evaluate(), NodeStatus::Success);
    }

    #[test]
    fn equal_failure_when_not_matching() {
        let (mut node, tx) = node_with_channel("state", ComparisonOperator::Equal, 1.0);
        tx.send(json!({ "state": 0.0 })).unwrap();
        assert_eq!(node.evaluate(), NodeStatus::Failure);
    }

    #[test]
    fn stale_topic_reuses_last_known_reading() {
        let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);

        tx.send(json!({ "distance": 0.1 })).unwrap();
        assert_eq!(node.evaluate(), NodeStatus::Success);

        assert_eq!(node.evaluate(), NodeStatus::Success);
        assert_eq!(node.evaluate(), NodeStatus::Success);
    }

    #[test]
    fn later_message_overrides_earlier_one() {
        let (mut node, tx) = node_with_channel("distance", ComparisonOperator::LessThan, 0.5);

        tx.send(json!({ "distance": 0.1 })).unwrap();
        tx.send(json!({ "distance": 1.0 })).unwrap();

        assert_eq!(node.evaluate(), NodeStatus::Failure);
    }

    #[test]
    fn from_json_parses_all_operators() {
        for operator in ["less_than", "greater_than", "equal"] {
            let config = json!({
                "topic": "/sensors/distance",
                "field": "value",
                "operator": operator,
                "threshold": 0.5
            });
            assert!(SensorCheckNode::from_json(config).is_ok());
        }
    }

    #[test]
    fn from_json_rejects_unknown_operator() {
        let config = json!({
            "topic": "/sensors/distance",
            "field": "value",
            "operator": "not_a_real_operator",
            "threshold": 0.5
        });
        assert!(SensorCheckNode::from_json(config).is_err());
    }
}