diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt
index 9a94c95e8..7ccb5f05d 100644
--- a/examples/CMakeLists.txt
+++ b/examples/CMakeLists.txt
@@ -35,6 +35,7 @@ CompileExample("t12_default_ports")
CompileExample("t13_access_by_ref")
CompileExample("t14_subtree_model")
CompileExample("t15_nodes_mocking")
+CompileExample("t15_nodes_mocking_strict_failure")
CompileExample("t16_global_blackboard")
CompileExample("t17_blackboard_backup")
CompileExample("t18_waypoints")
diff --git a/examples/t15_nodes_mocking.cpp b/examples/t15_nodes_mocking.cpp
index 805a46b95..6e6007066 100644
--- a/examples/t15_nodes_mocking.cpp
+++ b/examples/t15_nodes_mocking.cpp
@@ -39,7 +39,7 @@ const char* xml_text = R"(
/**
* @brief In this example we will see how we can substitute some nodes
- * in the Tree above with
+ * in the Tree above with mocks.
* @param argc
* @param argv
* @return
diff --git a/examples/t15_nodes_mocking_strict_failure.cpp b/examples/t15_nodes_mocking_strict_failure.cpp
new file mode 100644
index 000000000..c3374ba5e
--- /dev/null
+++ b/examples/t15_nodes_mocking_strict_failure.cpp
@@ -0,0 +1,143 @@
+#include "dummy_nodes.h"
+
+#include "behaviortree_cpp/bt_factory.h"
+
+// clang-format off
+namespace
+{
+const char* xml_text = R"(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )";
+} // namespace
+// clang-format on
+
+/**
+ * @brief Companion to tutorial 15 that preserves strict failure propagation.
+ *
+ * If the substituted TestNode resolves to FAILURE, the enclosing Sequence stops
+ * immediately. The final blackboard message is still printed by this executable,
+ * but the tree itself does not log the failure branch through a fallback.
+ */
+
+int main(int /*argc*/, char** /*argv*/)
+{
+ using namespace DummyNodes;
+ BT::BehaviorTreeFactory factory;
+ factory.registerNodeType("SaySomething");
+ factory.registerBehaviorTreeFromText(xml_text);
+
+ {
+ auto tree = factory.createTree("MainTree");
+
+ std::cout << "----- Nodes fullPath() -------\n";
+ tree.applyVisitor(
+ [](BT::TreeNode* node) { std::cout << node->fullPath() << std::endl; });
+
+ std::cout << "\n------ Output (original) ------\n";
+ auto status = tree.tickWhileRunning();
+ std::cout << "Original status: " << BT::toStr(status, false) << std::endl;
+ }
+
+ factory.registerSimpleAction("DummyAction", [](BT::TreeNode& self) {
+ std::cout << "DummyAction substituting node with fullPath(): " << self.fullPath()
+ << std::endl;
+ return BT::NodeStatus::SUCCESS;
+ });
+
+ factory.registerSimpleAction("DummySaySomething", [](BT::TreeNode& self) {
+ auto msg = self.getInput("message");
+ std::cout << "DummySaySomething: " << msg.value() << std::endl;
+ return BT::NodeStatus::SUCCESS;
+ });
+
+ BT::TestNodeConfig test_config;
+ test_config.return_status_script = "(mock_should_fail == true) ? FAILURE : SUCCESS";
+ test_config.async_delay = std::chrono::milliseconds(2000);
+ test_config.success_script = "msg := 'message SUBSTITUTED' ";
+ test_config.failure_script = "msg := 'message FAILURE branch' ";
+
+ BT::TestNodeConfig counting_config;
+ counting_config.return_status = BT::NodeStatus::SUCCESS;
+
+ factory.addSubstitutionRule("mysub/action_*", "DummyAction");
+ factory.addSubstitutionRule("talk", "DummySaySomething");
+ factory.addSubstitutionRule("set_message", test_config);
+ factory.addSubstitutionRule("counting", counting_config);
+
+ auto blackboard = BT::Blackboard::create();
+ auto tree = factory.createTree("MainTree", blackboard);
+ std::cout << "\n------ Output (substituted, strict failure) ------\n";
+ auto status = tree.tickWhileRunning();
+ std::cout << "Substituted status: " << BT::toStr(status, false) << std::endl;
+ if(blackboard->getEntry("msg"))
+ {
+ std::cout << "Substituted final msg: " << blackboard->get("msg")
+ << std::endl;
+ }
+
+ return 0;
+}
+
+/* Expected output:
+
+----- Nodes fullPath() -------
+Sequence::1
+talk
+mysub
+mysub/Sequence::4
+mysub/action_subA
+mysub/action_subB
+set_mock_flag
+set_message
+SaySomething::9
+counting
+SaySomething::11
+SaySomething::12
+SaySomething::13
+
+------ Output (original) ------
+Robot says: hello world
+Robot says: the original message
+Robot says: 1
+Robot says: 2
+Robot says: 3
+Original status: SUCCESS
+
+------ Output (substituted, strict failure) ------
+DummySaySomething: hello world
+DummyAction substituting node with fullPath(): mysub/action_subA
+DummyAction substituting node with fullPath(): mysub/action_subB
+Substituted status: SUCCESS
+Substituted final msg: message SUBSTITUTED
+
+If you change mock_should_fail to true in the XML above, the substituted status
+becomes FAILURE and the final msg becomes "message FAILURE branch".
+
+*/
diff --git a/include/behaviortree_cpp/actions/test_node.h b/include/behaviortree_cpp/actions/test_node.h
index 17b91a23f..c675d2e4c 100644
--- a/include/behaviortree_cpp/actions/test_node.h
+++ b/include/behaviortree_cpp/actions/test_node.h
@@ -14,15 +14,19 @@
#pragma once
#include "behaviortree_cpp/action_node.h"
-#include "behaviortree_cpp/scripting/script_parser.hpp"
-#include "behaviortree_cpp/utils/timer_queue.h"
+
+#include
+#include
+#include
+#include
namespace BT
{
struct TestNodeConfig
{
- /// status to return when the action is completed.
+ /// Status to return when the action is completed.
+ /// If return_status_script is also set, the script takes precedence.
NodeStatus return_status = NodeStatus::SUCCESS;
/// script to execute when complete_func() returns SUCCESS
@@ -38,14 +42,27 @@ struct TestNodeConfig
std::chrono::milliseconds async_delay = std::chrono::milliseconds(0);
/// Function invoked when the action is completed.
- /// If not specified, the node will return [return_status]
+ /// If not specified, the node will use return_status_script when present,
+ /// otherwise it will return [return_status].
std::function complete_func;
+
+ /// Optional script to compute the completion status dynamically.
+ ///
+ /// This script is evaluated when the TestNode completes, after any
+ /// async_delay has elapsed, using the current blackboard state.
+ /// The result must resolve to the same set of statuses supported by
+ /// return_status, except IDLE which is always rejected.
+ /// When set, this takes precedence over return_status.
+ ///
+ /// NOTE: kept last to preserve the byte offsets of the fields above.
+ std::string return_status_script;
};
/**
* @brief The TestNode is a Node that can be configure to:
*
* 1. Return a specific status (SUCCESS / FAILURE)
+ * 1.b Compute the returned status from a script evaluated at completion time
* 2. Execute a post condition script (unless halted)
* 3. Either complete immediately (synchronous action), or after a
* given period of time (asynchronous action)
@@ -73,26 +90,32 @@ class TestNode : public BT::StatefulActionNode
TestNode(const std::string& name, const NodeConfig& config,
std::shared_ptr test_config);
+ ~TestNode() override;
+
+ TestNode(const TestNode& other) = delete;
+ TestNode& operator=(const TestNode& other) = delete;
+
+ TestNode(TestNode&& other) = delete;
+ TestNode& operator=(TestNode&& other) = delete;
+
static PortsList providedPorts()
{
return {};
}
protected:
- virtual NodeStatus onStart() override;
+ NodeStatus onStart() override;
- virtual NodeStatus onRunning() override;
+ NodeStatus onRunning() override;
- virtual void onHalted() override;
+ void onHalted() override;
NodeStatus onCompleted();
- std::shared_ptr _config;
- ScriptFunction _success_executor;
- ScriptFunction _failure_executor;
- ScriptFunction _post_executor;
- TimerQueue<> _timer;
- std::atomic_bool _completed = false;
+ // All state lives behind this pointer, so that adding or changing it does not
+ // alter the layout that consumers compile against.
+ struct PImpl;
+ std::unique_ptr _p;
};
} // namespace BT
diff --git a/src/actions/test_node.cpp b/src/actions/test_node.cpp
index f2471c085..6990ffe14 100644
--- a/src/actions/test_node.cpp
+++ b/src/actions/test_node.cpp
@@ -1,8 +1,93 @@
#include "behaviortree_cpp/actions/test_node.h"
+#include "behaviortree_cpp/scripting/script_parser.hpp"
+#include "behaviortree_cpp/utils/timer_queue.h"
+
+#include
+
namespace BT
{
+namespace
+{
+
+bool isAllowedTestNodeStatus(NodeStatus status)
+{
+ switch(status)
+ {
+ case NodeStatus::RUNNING:
+ case NodeStatus::SUCCESS:
+ case NodeStatus::FAILURE:
+ case NodeStatus::SKIPPED:
+ return true;
+
+ case NodeStatus::IDLE:
+ return false;
+ }
+ return false;
+}
+
+void validateTestNodeStatus(NodeStatus status, StringView source)
+{
+ if(!isAllowedTestNodeStatus(status))
+ {
+ throw RuntimeError("TestNode ", std::string(source),
+ " resolved to invalid status (integer value: ",
+ std::to_string(static_cast(status)), ")");
+ }
+}
+
+EnumsTablePtr createTestNodeEnums(const EnumsTablePtr& base_enums)
+{
+ auto enums = base_enums ? std::make_shared(*base_enums) :
+ std::make_shared();
+
+ (*enums)["IDLE"] = static_cast(NodeStatus::IDLE);
+ (*enums)["RUNNING"] = static_cast(NodeStatus::RUNNING);
+ (*enums)["SUCCESS"] = static_cast(NodeStatus::SUCCESS);
+ (*enums)["FAILURE"] = static_cast(NodeStatus::FAILURE);
+ (*enums)["SKIPPED"] = static_cast(NodeStatus::SKIPPED);
+ return enums;
+}
+
+NodeStatus convertScriptResultToStatus(const Any& result)
+{
+ if(result.empty())
+ {
+ throw RuntimeError("return_status_script returned an empty value");
+ }
+
+ if(result.isString())
+ {
+ auto status = convertFromString(result.cast());
+ validateTestNodeStatus(status, "return_status_script");
+ return status;
+ }
+
+ if(auto status = result.tryCast())
+ {
+ auto resolved = static_cast(status.value());
+ validateTestNodeStatus(resolved, "return_status_script");
+ return resolved;
+ }
+
+ throw RuntimeError("return_status_script must evaluate to a NodeStatus-compatible "
+ "value");
+}
+
+} // namespace
+
+struct TestNode::PImpl
+{
+ std::shared_ptr config;
+ ScriptFunction return_status_executor;
+ ScriptFunction success_executor;
+ ScriptFunction failure_executor;
+ ScriptFunction post_executor;
+ TimerQueue<> timer;
+ std::atomic_bool completed = false;
+};
+
TestNode::TestNode(const std::string& name, const NodeConfig& config,
TestNodeConfig test_config)
: TestNode(name, config, std::make_shared(std::move(test_config)))
@@ -10,13 +95,14 @@ TestNode::TestNode(const std::string& name, const NodeConfig& config,
TestNode::TestNode(const std::string& name, const NodeConfig& config,
std::shared_ptr test_config)
- : StatefulActionNode(name, config), _config(std::move(test_config))
+ : StatefulActionNode(name, config), _p(std::make_unique())
{
+ _p->config = std::move(test_config);
setRegistrationID("TestNode");
- if(_config->return_status == NodeStatus::IDLE)
+ if(_p->config->return_status_script.empty())
{
- throw RuntimeError("TestNode can not return IDLE");
+ validateTestNodeStatus(_p->config->return_status, "return_status");
}
auto prepareScript = [](const std::string& script, auto& executor) {
@@ -30,29 +116,32 @@ TestNode::TestNode(const std::string& name, const NodeConfig& config,
executor = result.value();
}
};
- prepareScript(_config->success_script, _success_executor);
- prepareScript(_config->failure_script, _failure_executor);
- prepareScript(_config->post_script, _post_executor);
+ prepareScript(_p->config->return_status_script, _p->return_status_executor);
+ prepareScript(_p->config->success_script, _p->success_executor);
+ prepareScript(_p->config->failure_script, _p->failure_executor);
+ prepareScript(_p->config->post_script, _p->post_executor);
}
+TestNode::~TestNode() = default;
+
NodeStatus TestNode::onStart()
{
- if(_config->async_delay <= std::chrono::milliseconds(0))
+ if(_p->config->async_delay <= std::chrono::milliseconds(0))
{
return onCompleted();
}
// convert this in an asynchronous operation. Use another thread to count
// a certain amount of time.
- _completed = false;
- _timer.add(std::chrono::milliseconds(_config->async_delay), [this](bool aborted) {
+ _p->completed = false;
+ _p->timer.add(std::chrono::milliseconds(_p->config->async_delay), [this](bool aborted) {
if(!aborted)
{
- _completed.store(true);
+ _p->completed.store(true);
this->emitWakeUpSignal();
}
else
{
- _completed.store(false);
+ _p->completed.store(false);
}
});
return NodeStatus::RUNNING;
@@ -60,7 +149,7 @@ NodeStatus TestNode::onStart()
NodeStatus TestNode::onRunning()
{
- if(_completed)
+ if(_p->completed)
{
return onCompleted();
}
@@ -69,27 +158,48 @@ NodeStatus TestNode::onRunning()
void TestNode::onHalted()
{
- _timer.cancelAll();
+ _p->timer.cancelAll();
}
NodeStatus TestNode::onCompleted()
{
- Ast::Environment env = { config().blackboard, config().enums };
+ NodeStatus status = NodeStatus::IDLE;
- auto status =
- (_config->complete_func) ? _config->complete_func() : _config->return_status;
+ if(_p->config->complete_func)
+ {
+ status = _p->config->complete_func();
+ }
+ else if(_p->return_status_executor)
+ {
+ // return_status_script may reference NodeStatus names (e.g. SUCCESS, FAILURE),
+ // so it is evaluated with an enum table augmented with those values. The
+ // post-execution scripts below deliberately do NOT see these names.
+ auto status_enums = createTestNodeEnums(config().enums);
+ Ast::Environment status_env = { config().blackboard, status_enums };
+ status = convertScriptResultToStatus(_p->return_status_executor(status_env));
+ }
+ else
+ {
+ status = _p->config->return_status;
+ }
+
+ validateTestNodeStatus(status, "completion");
+
+ // success/failure/post scripts use only the node's own enums, so that a
+ // blackboard entry named like a status (e.g. "SUCCESS") is not shadowed.
+ Ast::Environment env = { config().blackboard, config().enums };
- if(status == NodeStatus::SUCCESS && _success_executor)
+ if(status == NodeStatus::SUCCESS && _p->success_executor)
{
- _success_executor(env);
+ _p->success_executor(env);
}
- else if(status == NodeStatus::FAILURE && _failure_executor)
+ else if(status == NodeStatus::FAILURE && _p->failure_executor)
{
- _failure_executor(env);
+ _p->failure_executor(env);
}
- if(_post_executor)
+ if(_p->post_executor)
{
- _post_executor(env);
+ _p->post_executor(env);
}
return status;
}
diff --git a/src/bt_factory.cpp b/src/bt_factory.cpp
index acd907a31..a3ef80def 100644
--- a/src/bt_factory.cpp
+++ b/src/bt_factory.cpp
@@ -531,9 +531,19 @@ void BehaviorTreeFactory::loadSubstitutionRuleFromJSON(const std::string& json_t
for(auto const& [name, test_config] : test_configs.items())
{
auto& config = configs[name];
+ bool has_return_status = false;
- auto return_status = test_config.at("return_status").get();
- config.return_status = convertFromString(return_status);
+ if(test_config.contains("return_status"))
+ {
+ auto return_status = test_config.at("return_status").get();
+ config.return_status = convertFromString(return_status);
+ has_return_status = true;
+ }
+ if(test_config.contains("return_status_script"))
+ {
+ config.return_status_script =
+ test_config["return_status_script"].get();
+ }
if(test_config.contains("async_delay"))
{
config.async_delay =
@@ -551,6 +561,12 @@ void BehaviorTreeFactory::loadSubstitutionRuleFromJSON(const std::string& json_t
{
config.failure_script = test_config["failure_script"].get();
}
+
+ if(!has_return_status && config.return_status_script.empty())
+ {
+ throw RuntimeError("TestNodeConfig [", name,
+ "] must contain return_status or return_status_script");
+ }
}
}
diff --git a/tests/gtest_substitution.cpp b/tests/gtest_substitution.cpp
index fda7afdfc..c64f6b1b6 100644
--- a/tests/gtest_substitution.cpp
+++ b/tests/gtest_substitution.cpp
@@ -19,13 +19,18 @@ const char* json_text = R"(
},
"TestB": {
"return_status": "FAILURE"
+ },
+ "TestScript": {
+ "return_status_script": "(mock_should_fail == true) ? FAILURE : SUCCESS",
+ "failure_script": "branch := 'failure'"
}
},
"SubstitutionRules": {
"actionA": "TestA",
"actionB": "TestB",
- "actionC": "NotAConfig"
+ "actionC": "NotAConfig",
+ "actionD": "TestScript"
}
}
)";
@@ -48,10 +53,11 @@ TEST(Substitution, Parser)
const auto& rules = factory.substitutionRules();
- ASSERT_EQ(rules.size(), 3);
+ ASSERT_EQ(rules.size(), 4);
ASSERT_EQ(rules.count("actionA"), 1);
ASSERT_EQ(rules.count("actionB"), 1);
ASSERT_EQ(rules.count("actionC"), 1);
+ ASSERT_EQ(rules.count("actionD"), 1);
auto configA = std::get_if(&rules.at("actionA"));
ASSERT_EQ(configA->return_status, NodeStatus::SUCCESS);
@@ -63,6 +69,12 @@ TEST(Substitution, Parser)
ASSERT_EQ(configB->async_delay, std::chrono::milliseconds(0));
ASSERT_TRUE(configB->post_script.empty());
+ auto configScript = std::get_if(&rules.at("actionD"));
+ ASSERT_FALSE(configScript->return_status_script.empty());
+ ASSERT_EQ(configScript->return_status_script, "(mock_should_fail == true) ? FAILURE : "
+ "SUCCESS");
+ ASSERT_EQ(configScript->failure_script, "branch := 'failure'");
+
ASSERT_EQ(*std::get_if(&rules.at("actionC")), "NotAConfig");
}
@@ -102,6 +114,145 @@ TEST(Substitution, TestNodePostScriptReadsStatusNamedBlackboardKey)
ASSERT_EQ(blackboard->get("observed"), 99);
}
+TEST(Substitution, ParserRejectsMissingReturnStatusAndScript)
+{
+ static const char* invalid_json = R"(
+ {
+ "TestNodeConfigs": {
+ "BrokenConfig": {
+ "post_script": "branch := 'unused'"
+ }
+ },
+ "SubstitutionRules": {
+ "actionA": "BrokenConfig"
+ }
+ }
+ )";
+
+ BehaviorTreeFactory factory;
+ ASSERT_THROW(factory.loadSubstitutionRuleFromJSON(invalid_json), RuntimeError);
+}
+
+TEST(Substitution, ScriptedReturnStatusFromJson)
+{
+ static const char* xml_text = R"(
+
+
+
+
+
+
+
+
+ )";
+
+ static const char* json_rules = R"(
+ {
+ "TestNodeConfigs": {
+ "ConditionalMock": {
+ "return_status_script": "(mock_should_fail == true) ? FAILURE : SUCCESS",
+ "success_script": "branch := 'success'",
+ "failure_script": "branch := 'failure'"
+ }
+ },
+ "SubstitutionRules": {
+ "action_A": "ConditionalMock"
+ }
+ }
+ )";
+
+ BehaviorTreeFactory factory;
+ factory.loadSubstitutionRuleFromJSON(json_rules);
+ factory.registerBehaviorTreeFromText(xml_text);
+
+ auto blackboard = Blackboard::create();
+ auto tree = factory.createTree("MainTree", blackboard);
+
+ ASSERT_EQ(tree.tickWhileRunning(), NodeStatus::FAILURE);
+ ASSERT_EQ(blackboard->get("branch"), "failure");
+}
+
+TEST(Substitution, ScriptedReturnStatusOverridesFixedStatus)
+{
+ static const char* xml_text = R"(
+
+
+
+
+
+
+
+
+ )";
+
+ BehaviorTreeFactory factory;
+ factory.registerBehaviorTreeFromText(xml_text);
+
+ TestNodeConfig test_config;
+ test_config.return_status = NodeStatus::SUCCESS;
+ test_config.return_status_script = "(mock_should_fail == true) ? FAILURE : SUCCESS";
+ test_config.failure_script = "branch := 'failure'";
+ factory.addSubstitutionRule("action_A", test_config);
+
+ auto blackboard = Blackboard::create();
+ auto tree = factory.createTree("MainTree", blackboard);
+
+ ASSERT_EQ(tree.tickWhileRunning(), NodeStatus::FAILURE);
+ ASSERT_EQ(blackboard->get("branch"), "failure");
+}
+
+TEST(Substitution, ScriptedReturnStatusAsyncSubstitution)
+{
+ static const char* xml_text = R"(
+
+
+
+
+
+
+
+
+ )";
+
+ BehaviorTreeFactory factory;
+ factory.registerBehaviorTreeFromText(xml_text);
+
+ TestNodeConfig test_config;
+ test_config.return_status_script = "(mock_should_fail == true) ? FAILURE : SUCCESS";
+ test_config.async_delay = std::chrono::milliseconds(50);
+ factory.addSubstitutionRule("action_A", test_config);
+
+ auto tree = factory.createTree("MainTree");
+ auto future =
+ std::async(std::launch::async, [&tree]() { return tree.tickWhileRunning(); });
+
+ auto status = future.wait_for(std::chrono::seconds(5));
+ ASSERT_NE(status, std::future_status::timeout) << "Tree hung! tickWhileRunning did not "
+ "complete within 5 seconds";
+ ASSERT_EQ(future.get(), NodeStatus::SUCCESS);
+}
+
+TEST(Substitution, ScriptedReturnStatusRejectsIdle)
+{
+ static const char* xml_text = R"(
+
+
+
+
+
+ )";
+
+ BehaviorTreeFactory factory;
+ factory.registerBehaviorTreeFromText(xml_text);
+
+ TestNodeConfig test_config;
+ test_config.return_status_script = "IDLE";
+ factory.addSubstitutionRule("action_A", test_config);
+
+ auto tree = factory.createTree("MainTree");
+ ASSERT_THROW(tree.tickWhileRunning(), RuntimeError);
+}
+
// Regression test for issue #934: segfault when substituting a SubTree node
TEST(Substitution, SubTreeNodeSubstitution)
{