Files
move_base2/test/walking_skeleton_test.cpp
2026-07-30 09:24:47 +07:00

1119 lines
38 KiB
C++

/*********************************************************************
*
* Software License Agreement (BSD License)
*
* move_base2 — test bộ khung chạy end-to-end với thành phần giả.
*
* Đây là test chứng minh contract giữa các cổng đã khớp: nhận yêu cầu, đi qua đủ state, gọi đúng
* cổng, phát đúng lệnh vận tốc, và báo kết quả đúng một lần.
*
* Author: DuongTD
*********************************************************************/
#include <gtest/gtest.h>
#include <algorithm>
#include <string>
#include <vector>
#include <move_base2/control_loop.h>
#include "fake_ports.h"
using move_base2::ControlLoop;
using move_base2::ControlLoopConfig;
using move_base2::ControlLoopDeps;
using move_base2::MotionProfile;
using move_base2::NavigationRequest;
using move_base2::NavigationState;
using move_base2::RecoveryTrigger;
using move_base2::testing::ActionScript;
using move_base2::testing::ControllerScript;
using move_base2::testing::FakeActionPort;
using move_base2::testing::FakeClockPort;
using move_base2::testing::FakeControllerPort;
using move_base2::testing::FakeMissionPort;
using move_base2::testing::FakePlannerPort;
using move_base2::testing::FakePosePort;
using move_base2::testing::FakeRecoveryPort;
using move_base2::testing::PlannerScript;
using move_base2::testing::RecoveryScript;
namespace
{
constexpr double kControlPeriod = 0.05; ///< [s]
ControlLoopConfig baseConfig()
{
ControlLoopConfig config;
config.state_machine.planner_patience = 0.5; // [s]
config.state_machine.controller_patience = 0.5; // [s]
config.state_machine.oscillation_timeout = 0.0; // tắt
config.state_machine.oscillation_distance = 0.5; // [m]
config.state_machine.max_planning_retries = -1;
config.state_machine.recovery_behavior_count = 2;
config.state_machine.recovery_enabled = true;
config.velocity.max_vel_x = 0.5; // [m/s]
config.velocity.min_vel_x = -0.2; // [m/s]
config.velocity.max_vel_theta = 1.0; // [rad/s]
config.velocity.max_accel_x = 100.0; // [m/s^2] lớn để test tập trung vào state, không vào ramp
config.velocity.max_accel_theta = 100.0; // [rad/s^2]
config.nominal_control_period = kControlPeriod;
config.position.global_planner_name = "FakeGlobalPlanner";
config.position.local_planner_name = "FakeLocalPlanner";
config.position.default_xy_tolerance = 0.15; // [m]
config.position.default_yaw_tolerance = 0.10; // [rad]
config.docking = config.position;
config.docking.local_planner_name = "FakeDockPlanner";
config.go_straight = config.position;
config.go_straight.local_planner_name = "FakeStraightPlanner";
config.rotate = config.position;
config.rotate.local_planner_name = "FakeRotatePlanner";
return config;
}
NavigationRequest makeRequest(double goal_x, std::uint64_t sequence_id = 0)
{
NavigationRequest request;
request.profile = MotionProfile::kPosition;
request.goal.header.frame_id = "map";
request.goal.pose.position.x = goal_x;
request.goal.pose.orientation.w = 1.0;
request.mission_sequence_id = sequence_id;
return request;
}
/// @brief Gắn @p count action tên "act_<i>" vào yêu cầu — kiểm được thứ tự tới ActionPort (D8).
NavigationRequest withActions(NavigationRequest request, std::size_t count)
{
for (std::size_t i = 0; i < count; ++i)
{
robot_protocol_msgs::Action action;
action.actionType = "act_" + std::to_string(i);
request.actions.push_back(action);
}
return request;
}
/// @brief Yêu cầu chỉ-có-action (D8): has_goal = false, goal để mặc định — không được validate.
NavigationRequest makeActionOnlyRequest(std::size_t count, std::uint64_t sequence_id = 0)
{
NavigationRequest request;
request.has_goal = false;
request.mission_sequence_id = sequence_id;
return withActions(request, count);
}
/**
* @class Fixture
* @brief Dựng sẵn control loop nối với toàn bộ cổng giả.
*/
class Fixture
{
public:
explicit Fixture(const ControlLoopConfig& config = baseConfig()) : recovery_(2)
{
pose_.setPosition(0.0, 0.0);
deps_.clock = &clock_;
deps_.pose = &pose_;
deps_.planner = &planner_;
deps_.controller = &controller_;
deps_.recovery = &recovery_;
deps_.mission = &mission_;
deps_.action = &action_;
std::string error;
EXPECT_TRUE(loop_.configure(config, deps_, error)) << error;
}
/// @brief Chạy tối đa @p max_cycles cycle, ghi lại chuỗi state (đã nén lặp liên tiếp).
void run(std::size_t max_cycles = 200)
{
for (std::size_t i = 0; i < max_cycles; ++i)
{
record();
if (!loop_.step())
{
record();
return;
}
clock_.advance(kControlPeriod);
}
hit_limit_ = true;
}
/// @brief Chạy đúng một cycle và ghi lại state sau cycle đó.
void stepOnce()
{
loop_.step();
clock_.advance(kControlPeriod);
record();
}
const std::vector<std::string>& states() const
{
return states_;
}
bool hitLimit() const
{
return hit_limit_;
}
ControlLoop loop_;
FakeClockPort clock_;
FakePosePort pose_;
FakePlannerPort planner_;
FakeControllerPort controller_;
FakeRecoveryPort recovery_;
FakeMissionPort mission_;
FakeActionPort action_;
ControlLoopDeps deps_;
private:
void record()
{
const std::string name = move_base2::toString(loop_.state());
if (states_.empty() || states_.back() != name)
{
states_.push_back(name);
}
}
std::vector<std::string> states_;
bool hit_limit_ = false;
};
std::string join(const std::vector<std::string>& items)
{
std::string out = "[";
for (std::size_t i = 0; i < items.size(); ++i)
{
out += items[i];
if (i + 1 < items.size())
{
out += ", ";
}
}
return out + "]";
}
} // namespace
// ================================================================================================
// Cấu hình và cửa vào
// ================================================================================================
TEST(ControlLoop, RefusesToConfigureWithMissingPorts)
{
ControlLoop loop;
ControlLoopDeps deps; // toàn null
std::string error;
EXPECT_FALSE(loop.configure(baseConfig(), deps, error));
EXPECT_NE(error.find("cổng"), std::string::npos);
EXPECT_FALSE(loop.initialized());
}
TEST(ControlLoop, RefusesToConfigureWithInvalidVelocityLimits)
{
Fixture fixture;
ControlLoopConfig bad = baseConfig();
bad.velocity.max_vel_x = -1.0;
ControlLoop loop;
std::string error;
EXPECT_FALSE(loop.configure(bad, fixture.deps_, error));
}
TEST(ControlLoop, RefusesToConfigureWithoutPositionPlanner)
{
Fixture fixture;
ControlLoopConfig bad = baseConfig();
bad.position.local_planner_name.clear();
ControlLoop loop;
std::string error;
EXPECT_FALSE(loop.configure(bad, fixture.deps_, error));
EXPECT_NE(error.find("position"), std::string::npos);
}
TEST(ControlLoop, RejectsGoalWithInvalidQuaternion)
{
Fixture fixture;
NavigationRequest request = makeRequest(3.0);
request.goal.pose.orientation.w = 0.0; // norm = 0
std::string reason;
EXPECT_FALSE(fixture.loop_.submit(request, reason));
EXPECT_NE(reason.find("quaternion"), std::string::npos);
}
TEST(ControlLoop, RejectsGoalWithNonFiniteCoordinates)
{
Fixture fixture;
NavigationRequest request = makeRequest(3.0);
request.goal.pose.position.x = std::numeric_limits<double>::quiet_NaN();
std::string reason;
EXPECT_FALSE(fixture.loop_.submit(request, reason));
}
TEST(ControlLoop, RejectsRequestWhenLocalPlannerCannotBeLoaded)
{
Fixture fixture;
fixture.controller_.setSwapSucceeds(false);
std::string reason;
EXPECT_FALSE(fixture.loop_.submit(makeRequest(3.0), reason));
EXPECT_NE(reason.find("local planner"), std::string::npos);
}
TEST(ControlLoop, RejectsRequestWhenGlobalPlannerCannotBeLoaded)
{
Fixture fixture;
fixture.planner_.setSwapSucceeds(false);
std::string reason;
EXPECT_FALSE(fixture.loop_.submit(makeRequest(3.0), reason));
EXPECT_NE(reason.find("global planner"), std::string::npos);
}
TEST(ControlLoop, ProfileSelectsItsOwnLocalPlannerAndTolerances)
{
Fixture fixture;
NavigationRequest docking = makeRequest(1.0);
docking.profile = MotionProfile::kDocking;
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(docking, reason)) << reason;
EXPECT_EQ(fixture.controller_.activeController(), "FakeDockPlanner");
EXPECT_NEAR(fixture.controller_.xyTolerance(), 0.15, 1e-9) << "tolerance 0 -> dùng default profile";
NavigationRequest rotate = makeRequest(1.0);
rotate.profile = MotionProfile::kRotate;
rotate.tolerance.yaw = 0.02; // [rad]
ASSERT_TRUE(fixture.loop_.submit(rotate, reason)) << reason;
EXPECT_EQ(fixture.controller_.activeController(), "FakeRotatePlanner");
EXPECT_NEAR(fixture.controller_.yawTolerance(), 0.02, 1e-9);
}
// ================================================================================================
// Đường đi thuận lợi
// ================================================================================================
TEST(ControlLoop, HappyPathReachesSucceededAndReportsOnce)
{
Fixture fixture;
fixture.planner_.setScript({PlannerScript::kOk});
fixture.controller_.setScript({ControllerScript::kOk, ControllerScript::kOk,
ControllerScript::kOk, ControllerScript::kGoalReached});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0, 42), reason)) << reason;
fixture.run();
EXPECT_FALSE(fixture.hitLimit());
EXPECT_EQ(fixture.states(), (std::vector<std::string>{"IDLE", "PLANNING", "CONTROLLING",
"SUCCEEDED"}))
<< join(fixture.states());
EXPECT_STREQ(fixture.loop_.lastOutcome(), "SUCCEEDED");
EXPECT_EQ(fixture.loop_.outcomeReportCount(), 1u);
EXPECT_EQ(fixture.mission_.reportCountFor(42), 1u)
<< "mỗi chặng chỉ được báo kết quả đúng một lần";
}
TEST(ControlLoop, DirectGoalWithoutMissionIdDoesNotTouchMissionLayer)
{
Fixture fixture;
fixture.controller_.setScript({ControllerScript::kOk, ControllerScript::kGoalReached});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0, 0), reason)) << reason;
fixture.run();
EXPECT_EQ(fixture.loop_.outcomeReportCount(), 1u);
EXPECT_TRUE(fixture.mission_.reports().empty())
<< "goal trực tiếp không thuộc mission nào thì không báo lên mission layer";
}
TEST(ControlLoop, ControllerCommandIsPublishedWhileControlling)
{
Fixture fixture;
fixture.controller_.setNominalSpeed(0.3);
fixture.controller_.setScript({ControllerScript::kOk});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.loop_.step(); // IDLE -> PLANNING (lập plan trong cycle này)
fixture.clock_.advance(kControlPeriod);
fixture.loop_.step(); // PLANNING -> CONTROLLING, chạy controller ngay
ASSERT_EQ(fixture.loop_.state(), NavigationState::kControlling);
EXPECT_NEAR(fixture.loop_.lastCommand().linear.x, 0.3, 1e-9);
}
TEST(ControlLoop, OrderIsForwardedToThePlanner)
{
Fixture fixture;
NavigationRequest request = makeRequest(3.0);
request.order = std::make_shared<robot_protocol_msgs::Order>();
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(request, reason)) << reason;
fixture.loop_.step();
EXPECT_TRUE(fixture.planner_.sawOrder());
}
// ================================================================================================
// Bất biến an toàn
// ================================================================================================
TEST(ControlLoop, NoVelocityIsEmittedOutsideControllingAndRecovering)
{
Fixture fixture;
fixture.controller_.setScript({ControllerScript::kOk, ControllerScript::kOk,
ControllerScript::kGoalReached});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
for (int i = 0; i < 50; ++i)
{
const bool running = fixture.loop_.step();
const NavigationState state = fixture.loop_.state();
if (move_base2::mustBeStopped(state))
{
ASSERT_DOUBLE_EQ(fixture.loop_.lastCommand().linear.x, 0.0)
<< "state " << move_base2::toString(state) << " ở cycle " << i;
ASSERT_DOUBLE_EQ(fixture.loop_.lastCommand().angular.z, 0.0)
<< "state " << move_base2::toString(state) << " ở cycle " << i;
}
if (!running)
{
break;
}
fixture.clock_.advance(kControlPeriod);
}
}
TEST(ControlLoop, NaNFromControllerIsBlockedByArbiter)
{
Fixture fixture;
fixture.controller_.setScript({ControllerScript::kNaN});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.loop_.step();
fixture.clock_.advance(kControlPeriod);
fixture.loop_.step(); // CONTROLLING, controller trả NaN
EXPECT_DOUBLE_EQ(fixture.loop_.lastCommand().linear.x, 0.0);
EXPECT_GT(fixture.loop_.arbiter().nonFiniteRejections(), 0u);
}
TEST(ControlLoop, OverspeedCommandIsClampedNotPassedThrough)
{
Fixture fixture;
fixture.controller_.setScript({ControllerScript::kTooFast});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.loop_.step();
fixture.clock_.advance(kControlPeriod);
fixture.loop_.step();
EXPECT_DOUBLE_EQ(fixture.loop_.lastCommand().linear.x, 0.5);
EXPECT_DOUBLE_EQ(fixture.loop_.lastCommand().angular.z, 1.0);
EXPECT_GT(fixture.loop_.arbiter().velocityClamps(), 0u);
}
TEST(ControlLoop, EmptyPlanIsTreatedAsFailureNotAsAValidPlan)
{
// Fake cố ý vi phạm contract (trả true kèm plan rỗng) để kiểm guard của bên gọi: plan rỗng lọt
// xuống sẽ thành front()/back() trên vector rỗng ở tầng dưới.
Fixture fixture;
fixture.planner_.setScript({PlannerScript::kEmpty});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.run();
EXPECT_EQ(fixture.controller_.setPlanCount(), 0u) << "không được đẩy plan rỗng xuống controller";
EXPECT_STREQ(fixture.loop_.lastOutcome(), "FAILED");
}
TEST(ControlLoop, LostPoseStopsTheRobotImmediately)
{
Fixture fixture;
fixture.controller_.setScript({ControllerScript::kOk});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.loop_.step();
fixture.clock_.advance(kControlPeriod);
fixture.loop_.step();
ASSERT_GT(fixture.loop_.lastCommand().linear.x, 0.0);
fixture.pose_.setAvailable(false);
fixture.clock_.advance(kControlPeriod);
fixture.loop_.step();
EXPECT_DOUBLE_EQ(fixture.loop_.lastCommand().linear.x, 0.0)
<< "mất TF thì phải dừng ngay, không đi tiếp bằng pose cũ";
}
// ================================================================================================
// Recovery
// ================================================================================================
TEST(ControlLoop, PlannerFailureDrivesRecoveryThenSucceeds)
{
// Kịch bản thật: planner bế tắc cho tới khi recovery gỡ được thế, sau đó lập plan bình thường.
// Lật kịch bản planner ngay khi quan sát thấy RECOVERING, thay vì đếm trước số cycle — đếm trước
// làm test phụ thuộc vào đúng thời điểm hết kiên nhẫn, thứ không phải là thứ đang được kiểm.
Fixture fixture;
fixture.planner_.setScript({PlannerScript::kFail});
fixture.controller_.setScript({ControllerScript::kOk, ControllerScript::kGoalReached});
fixture.recovery_.setScript({RecoveryScript::kRunning, RecoveryScript::kSucceeded});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0, 7), reason)) << reason;
bool planner_unblocked = false;
std::vector<std::string> states;
for (int i = 0; i < 200; ++i)
{
const std::string name = move_base2::toString(fixture.loop_.state());
if (states.empty() || states.back() != name)
{
states.push_back(name);
}
if (!planner_unblocked && fixture.loop_.state() == NavigationState::kRecovering)
{
fixture.planner_.setScript({PlannerScript::kOk});
planner_unblocked = true;
}
if (!fixture.loop_.step())
{
states.push_back(move_base2::toString(fixture.loop_.state()));
break;
}
fixture.clock_.advance(kControlPeriod);
}
EXPECT_TRUE(planner_unblocked) << "không bao giờ vào recovery";
EXPECT_EQ(states, (std::vector<std::string>{"IDLE", "PLANNING", "RECOVERING", "PLANNING",
"CONTROLLING", "SUCCEEDED"}))
<< join(states);
EXPECT_EQ(fixture.recovery_.startCount(), 1u);
EXPECT_EQ(fixture.recovery_.lastStartIndex(), 0u);
EXPECT_EQ(fixture.recovery_.lastTrigger(), RecoveryTrigger::kPlanningFailed);
EXPECT_EQ(fixture.mission_.reportCountFor(7), 1u);
}
TEST(ControlLoop, AllRecoveriesExhaustedEndsInAbortedWithSingleReport)
{
Fixture fixture;
fixture.planner_.setScript({PlannerScript::kFail}); // luôn hỏng
fixture.recovery_.setScript({RecoveryScript::kSucceeded});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0, 9), reason)) << reason;
fixture.run();
EXPECT_FALSE(fixture.hitLimit());
EXPECT_EQ(fixture.states(),
(std::vector<std::string>{"IDLE", "PLANNING", "RECOVERING", "PLANNING", "RECOVERING",
"PLANNING", "ABORTED"}))
<< join(fixture.states());
EXPECT_EQ(fixture.recovery_.startedIndices(), (std::vector<std::size_t>{0u, 1u}))
<< "phải chạy lần lượt từng behavior, không lặp lại behavior đầu";
EXPECT_STREQ(fixture.loop_.lastOutcome(), "FAILED");
EXPECT_EQ(fixture.loop_.outcomeReportCount(), 1u);
EXPECT_EQ(fixture.mission_.reportCountFor(9), 1u);
}
TEST(ControlLoop, RecoveryRefusingToStartMovesOnToTheNextBehavior)
{
Fixture fixture;
fixture.planner_.setScript({PlannerScript::kFail});
fixture.recovery_.setStartSucceeds(false);
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.run();
EXPECT_EQ(fixture.recovery_.startedIndices(), (std::vector<std::size_t>{0u, 1u}));
EXPECT_EQ(fixture.recovery_.updateCount(), 0u)
<< "không được tick một behavior chưa start thành công";
EXPECT_STREQ(fixture.loop_.lastOutcome(), "FAILED");
}
TEST(ControlLoop, RecoveryVelocityGoesThroughArbiterWithOneHandoverCycle)
{
Fixture fixture;
fixture.planner_.setScript({PlannerScript::kFail});
fixture.recovery_.setScript({RecoveryScript::kRunning, RecoveryScript::kRunning,
RecoveryScript::kSucceeded});
fixture.recovery_.setRecoveryVelocity(true, -0.15); // [m/s], âm = lùi
fixture.controller_.setScript({ControllerScript::kOk});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
bool saw_reverse = false;
for (int i = 0; i < 60; ++i)
{
const bool running = fixture.loop_.step();
if (fixture.loop_.state() == NavigationState::kRecovering &&
fixture.loop_.lastCommand().linear.x < -1e-6)
{
saw_reverse = true;
EXPECT_GE(fixture.loop_.lastCommand().linear.x, -0.2) << "lệnh lùi phải nằm trong trần";
}
if (!running)
{
break;
}
fixture.clock_.advance(kControlPeriod);
}
EXPECT_TRUE(saw_reverse) << "recovery phát vận tốc nhưng lệnh không tới được đầu ra";
}
TEST(ControlLoop, RecoveryDisabledAbortsOnFirstFailure)
{
ControlLoopConfig config = baseConfig();
config.state_machine.recovery_enabled = false;
config.state_machine.recovery_behavior_count = 0;
Fixture fixture(config);
fixture.planner_.setScript({PlannerScript::kFail});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.run();
EXPECT_EQ(fixture.recovery_.startCount(), 0u);
EXPECT_EQ(fixture.states(), (std::vector<std::string>{"IDLE", "PLANNING", "ABORTED"}))
<< join(fixture.states());
}
// ================================================================================================
// Pause / cancel
// ================================================================================================
TEST(ControlLoop, CancelWhileControllingStopsAndReportsCancelled)
{
Fixture fixture;
fixture.controller_.setScript({ControllerScript::kOk});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0, 5), reason)) << reason;
fixture.loop_.step();
fixture.clock_.advance(kControlPeriod);
fixture.loop_.step();
ASSERT_EQ(fixture.loop_.state(), NavigationState::kControlling);
ASSERT_GT(fixture.loop_.lastCommand().linear.x, 0.0);
fixture.loop_.requestCancel();
int cycles_to_zero = 0;
for (int i = 0; i < 10; ++i)
{
fixture.clock_.advance(kControlPeriod);
const bool running = fixture.loop_.step();
++cycles_to_zero;
if (!running)
{
break;
}
}
EXPECT_LE(cycles_to_zero, 2) << "cmd_vel phải về 0 trong vòng 2 cycle sau khi huỷ";
EXPECT_DOUBLE_EQ(fixture.loop_.lastCommand().linear.x, 0.0);
EXPECT_EQ(fixture.loop_.state(), NavigationState::kCancelled);
EXPECT_STREQ(fixture.loop_.lastOutcome(), "CANCELLED");
EXPECT_EQ(fixture.mission_.reportCountFor(5), 1u);
}
TEST(ControlLoop, CancelDuringRecoveryStopsTheBehavior)
{
Fixture fixture;
fixture.planner_.setScript({PlannerScript::kFail});
fixture.recovery_.setScript({RecoveryScript::kRunning});
fixture.recovery_.setRecoveryVelocity(true, -0.15);
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
for (int i = 0; i < 40 && fixture.loop_.state() != NavigationState::kRecovering; ++i)
{
fixture.loop_.step();
fixture.clock_.advance(kControlPeriod);
}
ASSERT_EQ(fixture.loop_.state(), NavigationState::kRecovering);
fixture.loop_.requestCancel();
fixture.loop_.step();
fixture.clock_.advance(kControlPeriod);
EXPECT_EQ(fixture.recovery_.cancelCount(), 1u);
EXPECT_DOUBLE_EQ(fixture.loop_.lastCommand().linear.x, 0.0);
fixture.loop_.step();
EXPECT_EQ(fixture.loop_.state(), NavigationState::kCancelled);
}
TEST(ControlLoop, PauseAndResumeMidControlling)
{
Fixture fixture;
fixture.controller_.setScript({ControllerScript::kOk});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.loop_.step();
fixture.clock_.advance(kControlPeriod);
fixture.loop_.step();
ASSERT_EQ(fixture.loop_.state(), NavigationState::kControlling);
fixture.loop_.requestPause();
fixture.clock_.advance(kControlPeriod);
fixture.loop_.step();
EXPECT_EQ(fixture.loop_.state(), NavigationState::kPaused);
EXPECT_DOUBLE_EQ(fixture.loop_.lastCommand().linear.x, 0.0);
// Dừng lâu rồi tiếp tục: không được vì thế mà rơi vào recovery.
fixture.clock_.advance(30.0);
fixture.loop_.requestResume();
fixture.loop_.step();
EXPECT_EQ(fixture.loop_.state(), NavigationState::kControlling);
EXPECT_GT(fixture.loop_.lastCommand().linear.x, 0.0);
}
// ================================================================================================
// Nhiều chặng liên tiếp
// ================================================================================================
TEST(ControlLoop, ThreeSequentialMissionLegsEachReportedExactlyOnce)
{
Fixture fixture;
for (std::uint64_t leg = 1; leg <= 3; ++leg)
{
fixture.planner_.setScript({PlannerScript::kOk});
fixture.controller_.setScript({ControllerScript::kOk, ControllerScript::kGoalReached});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(static_cast<double>(leg), leg), reason)) << reason;
for (int i = 0; i < 50; ++i)
{
const bool running = fixture.loop_.step();
fixture.clock_.advance(kControlPeriod);
if (!running)
{
break;
}
}
ASSERT_EQ(fixture.loop_.state(), NavigationState::kSucceeded) << "chặng " << leg;
ASSERT_EQ(fixture.mission_.reportCountFor(leg), 1u) << "chặng " << leg;
}
EXPECT_EQ(fixture.loop_.outcomeReportCount(), 3u);
EXPECT_EQ(fixture.mission_.reports().size(), 3u);
}
// ================================================================================================
// Action của mission (D8)
// ================================================================================================
TEST(ControlLoopActions, ActionOnlyMissionNeverTouchesPlannerOrController)
{
Fixture fixture;
fixture.action_.setScript({ActionScript::kRunning, ActionScript::kSucceeded,
ActionScript::kRunning, ActionScript::kSucceeded});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeActionOnlyRequest(2, 7), reason)) << reason;
fixture.run();
ASSERT_FALSE(fixture.hitLimit()) << join(fixture.states());
EXPECT_EQ(fixture.states(),
(std::vector<std::string>{"IDLE", "EXECUTING_ACTIONS", "SUCCEEDED"}))
<< join(fixture.states());
// Không một lời gọi nào tới planner/controller — và vì thế không một lệnh vận tốc nào khác 0.
EXPECT_EQ(fixture.planner_.makePlanCount(), 0u);
EXPECT_EQ(fixture.controller_.computeCount(), 0u);
EXPECT_EQ(fixture.loop_.lastCommand().linear.x, 0.0);
EXPECT_EQ(fixture.loop_.lastCommand().angular.z, 0.0);
// Hai action tới port nguyên vẹn, đúng thứ tự; kết quả báo đúng một lần sau action cuối.
EXPECT_EQ(fixture.action_.startedActionTypes(),
(std::vector<std::string>{"act_0", "act_1"}));
EXPECT_EQ(fixture.mission_.reportCountFor(7), 1u);
}
TEST(ControlLoopActions, MissionWithGoalRunsActionsAfterGoalReached)
{
Fixture fixture;
fixture.planner_.setScript({PlannerScript::kOk});
fixture.controller_.setScript({ControllerScript::kOk, ControllerScript::kGoalReached});
fixture.action_.setScript({ActionScript::kRunning, ActionScript::kSucceeded});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(withActions(makeRequest(2.0, 11), 1), reason)) << reason;
fixture.run();
ASSERT_FALSE(fixture.hitLimit()) << join(fixture.states());
EXPECT_EQ(fixture.states(),
(std::vector<std::string>{"IDLE", "PLANNING", "CONTROLLING", "EXECUTING_ACTIONS",
"SUCCEEDED"}))
<< join(fixture.states());
EXPECT_EQ(fixture.action_.startedActionTypes(), (std::vector<std::string>{"act_0"}));
EXPECT_EQ(fixture.mission_.reportCountFor(11), 1u);
}
TEST(ControlLoopActions, ActionFailureAbortsAndReportsOnce)
{
Fixture fixture;
fixture.action_.setScript({ActionScript::kRunning, ActionScript::kFailed});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeActionOnlyRequest(1, 13), reason)) << reason;
fixture.run();
ASSERT_FALSE(fixture.hitLimit()) << join(fixture.states());
EXPECT_EQ(fixture.loop_.state(), NavigationState::kAborted);
EXPECT_EQ(fixture.mission_.reportCountFor(13), 1u);
EXPECT_EQ(fixture.recovery_.startCount(), 0u) << "action hỏng không được kéo recovery vào";
}
TEST(ControlLoopActions, SubmitRejectsActionRequestWhenActionPortMissing)
{
Fixture fixture;
ControlLoop bare_loop;
ControlLoopDeps deps = fixture.deps_;
deps.action = nullptr;
std::string error;
ASSERT_TRUE(bare_loop.configure(baseConfig(), deps, error)) << error;
std::string reason;
EXPECT_FALSE(bare_loop.submit(makeActionOnlyRequest(1), reason));
EXPECT_NE(reason.find("action port"), std::string::npos) << reason;
// Không goal lẫn action thì bị từ chối bất kể có port hay không.
EXPECT_FALSE(fixture.loop_.submit(makeActionOnlyRequest(0), reason));
EXPECT_NE(reason.find("goal lẫn action"), std::string::npos) << reason;
}
// ================================================================================================
// Lập plan bất đồng bộ (kBusy)
//
// Global planner nặng mất hàng trăm ms, control loop chạy 20 Hz và là thread duy nhất phát cmd_vel.
// Các test dưới đây khoá lại điều kiện để tách được hai nhịp đó mà state machine không phải đoán.
// ================================================================================================
TEST(ControlLoopAsyncPlanner, StaysInPlanningWhileThePlannerIsStillWorking)
{
Fixture fixture;
fixture.planner_.setLatencyCycles(3);
fixture.controller_.setScript({ControllerScript::kOk});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.stepOnce(); // IDLE -> PLANNING, kick lượt lập plan
ASSERT_EQ(fixture.loop_.state(), NavigationState::kPlanning);
for (int i = 0; i < 3; ++i)
{
fixture.stepOnce();
EXPECT_EQ(fixture.loop_.state(), NavigationState::kPlanning)
<< "rời PLANNING khi planner còn đang tính, cycle " << i;
}
fixture.stepOnce();
EXPECT_EQ(fixture.loop_.state(), NavigationState::kControlling);
}
TEST(ControlLoopAsyncPlanner, DoesNotRestartAPlanThatIsAlreadyRunning)
{
// `start_planner` là tín hiệu MỨC, bật lại mỗi cycle chừng nào còn ở PLANNING. Kick lại một lượt
// đang chạy sẽ làm mất toàn bộ thời gian đã bỏ ra cho nó.
Fixture fixture;
fixture.planner_.setLatencyCycles(4);
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
for (int i = 0; i < 4; ++i)
{
fixture.stepOnce();
}
EXPECT_EQ(fixture.planner_.makePlanCount(), 1u)
<< "lượt lập plan bị khởi động lại mỗi cycle";
}
TEST(ControlLoopAsyncPlanner, PlannerPatienceStillFiresWhileThePlannerIsBusy)
{
// Đây là thứ DUY NHẤT phát hiện được thread planner treo. Ở chế độ đồng bộ, planner treo làm
// treo luôn control loop nên còn dễ thấy; ở chế độ bất đồng bộ nó im lặng hoàn toàn.
ControlLoopConfig config = baseConfig();
config.state_machine.planner_patience = 0.2; // [s] = 4 cycle
config.state_machine.max_planning_retries = -1;
Fixture fixture(config);
fixture.planner_.setLatencyCycles(1000); // không bao giờ xong
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
for (int i = 0; i < 20; ++i)
{
fixture.stepOnce();
}
// Planner treo vĩnh viễn thì đường đi đúng là: hết kiên nhẫn -> recovery -> thử lại -> hết
// recovery -> ABORTED. Điều phải khoá lại là nó KHÔNG đứng im ở PLANNING.
EXPECT_NE(std::find(fixture.states().begin(), fixture.states().end(), "RECOVERING"),
fixture.states().end())
<< "planner treo mà không ai escalate — robot đứng ở PLANNING vĩnh viễn: "
<< join(fixture.states());
EXPECT_EQ(fixture.loop_.state(), NavigationState::kAborted) << join(fixture.states());
}
TEST(ControlLoopAsyncPlanner, KeepsFollowingTheOldPlanWhileReplanningInBackground)
{
// Chính là lý do tồn tại của cả mô hình: lập lại plan không được làm gián đoạn việc bám plan cũ.
ControlLoopConfig config = baseConfig();
config.state_machine.controller_patience = 100.0; // [s] không cho controller hết kiên nhẫn
Fixture fixture(config);
fixture.controller_.setNominalSpeed(0.3); // [m/s]
fixture.controller_.setScript({ControllerScript::kOk});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.stepOnce(); // PLANNING
fixture.stepOnce(); // -> CONTROLLING
ASSERT_EQ(fixture.loop_.state(), NavigationState::kControlling);
for (int i = 0; i < 5; ++i)
{
fixture.stepOnce();
EXPECT_EQ(fixture.loop_.state(), NavigationState::kControlling);
EXPECT_NEAR(fixture.loop_.lastCommand().linear.x, 0.3, 1e-9)
<< "cmd_vel gián đoạn trong lúc lập lại plan, cycle " << i;
}
}
TEST(ControlLoopAsyncPlanner, CancelWhilePlanningCancelsTheRunningPlan)
{
// Không huỷ thì lượt đang bay vẫn về và chiếm chỗ hộp thư, rồi bị nhận nhầm cho yêu cầu kế tiếp.
Fixture fixture;
fixture.planner_.setLatencyCycles(100);
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.stepOnce();
ASSERT_EQ(fixture.loop_.state(), NavigationState::kPlanning);
ASSERT_TRUE(fixture.planner_.isPlanning());
fixture.loop_.requestCancel();
fixture.stepOnce();
EXPECT_GE(fixture.planner_.cancelCount(), 1u);
EXPECT_FALSE(fixture.planner_.isPlanning());
}
TEST(ControlLoopAsyncPlanner, AcceptingANewRequestCancelsAnInFlightPlan)
{
Fixture fixture;
fixture.planner_.setLatencyCycles(100);
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.stepOnce();
ASSERT_TRUE(fixture.planner_.isPlanning());
const std::size_t before = fixture.planner_.cancelCount();
fixture.loop_.requestCancel();
fixture.stepOnce();
fixture.stepOnce();
ASSERT_TRUE(fixture.loop_.submit(makeRequest(5.0), reason)) << reason;
fixture.stepOnce();
EXPECT_GT(fixture.planner_.cancelCount(), before)
<< "yêu cầu mới không huỷ lượt lập plan của goal cũ";
}
TEST(ControlLoopAsyncPlanner, FailureToStartAPlanIsTreatedAsAFailedAttempt)
{
// Không khởi động được mà im lặng thì state machine đứng ở PLANNING vô hạn: không có kFailed để
// đếm lượt, không có kPlanReady để đi tiếp.
ControlLoopConfig config = baseConfig();
config.state_machine.max_planning_retries = 2;
Fixture fixture(config);
fixture.planner_.setSwapSucceeds(true);
fixture.planner_.setScript({PlannerScript::kFail});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.run();
EXPECT_FALSE(fixture.hitLimit()) << join(fixture.states());
EXPECT_NE(std::find(fixture.states().begin(), fixture.states().end(), "RECOVERING"),
fixture.states().end())
<< join(fixture.states());
}
// ================================================================================================
// Preempt — goal mới thay goal cũ NGAY
//
// Bấm goal mới nghĩa là goal cũ không còn muốn nữa. Xếp hàng chờ robot đi hết chặng cũ là hành vi
// không ai mong đợi, và mission layer cũng đã chốt "preempt ngay" (Q1, Phase 2).
// ================================================================================================
TEST(ControlLoopPreempt, NewGoalWhileControllingReplansImmediately)
{
Fixture fixture;
fixture.controller_.setScript({ControllerScript::kOk});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.stepOnce(); // IDLE -> PLANNING
fixture.stepOnce(); // -> CONTROLLING
ASSERT_EQ(fixture.loop_.state(), NavigationState::kControlling);
const std::size_t plans_before = fixture.planner_.makePlanCount();
ASSERT_TRUE(fixture.loop_.submit(makeRequest(9.0), reason)) << reason;
fixture.stepOnce();
EXPECT_EQ(fixture.loop_.state(), NavigationState::kPlanning)
<< "goal mới nằm chờ thay vì thay goal cũ ngay";
EXPECT_GT(fixture.planner_.makePlanCount(), plans_before) << "không lập plan lại cho goal mới";
}
TEST(ControlLoopPreempt, NewGoalWhilePlanningReplansImmediately)
{
Fixture fixture;
fixture.planner_.setLatencyCycles(50); // plan cũ còn lâu mới xong
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
fixture.stepOnce();
ASSERT_EQ(fixture.loop_.state(), NavigationState::kPlanning);
ASSERT_TRUE(fixture.loop_.submit(makeRequest(9.0), reason)) << reason;
fixture.stepOnce();
EXPECT_EQ(fixture.loop_.state(), NavigationState::kPlanning);
EXPECT_GE(fixture.planner_.cancelCount(), 1u) << "lượt lập plan của goal cũ không bị huỷ";
}
TEST(ControlLoopPreempt, OldMissionIsReportedPreemptedExactlyOnceUnderItsOwnId)
{
// Bất biến quan trọng nhất: báo kết quả ĐÚNG MỘT LẦN và ĐÚNG ID. Preempt báo kết quả chặng cũ và
// nhận chặng mới trong cùng một cycle — dùng nhầm id thì mission layer mất dấu cả hai chặng.
Fixture fixture;
fixture.controller_.setScript({ControllerScript::kOk});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0, 11), reason)) << reason;
fixture.stepOnce();
fixture.stepOnce();
ASSERT_EQ(fixture.loop_.state(), NavigationState::kControlling);
ASSERT_TRUE(fixture.loop_.submit(makeRequest(9.0, 22), reason)) << reason;
fixture.stepOnce();
EXPECT_EQ(fixture.mission_.reportCountFor(11), 1u) << "chặng bị thay không được báo đúng một lần";
EXPECT_EQ(fixture.mission_.reportCountFor(22), 0u) << "chặng MỚI bị báo kết quả ngay khi nhận";
}
TEST(ControlLoopPreempt, PreemptedGoalStillFinishesTheNewOne)
{
// Preempt không được để lại trạng thái nửa vời: chặng mới phải chạy tới cùng như bình thường.
Fixture fixture;
fixture.controller_.setScript({ControllerScript::kOk, ControllerScript::kOk,
ControllerScript::kGoalReached});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0, 11), reason)) << reason;
fixture.stepOnce();
fixture.stepOnce();
ASSERT_TRUE(fixture.loop_.submit(makeRequest(9.0, 22), reason)) << reason;
fixture.run();
EXPECT_FALSE(fixture.hitLimit()) << join(fixture.states());
EXPECT_STREQ(fixture.loop_.lastOutcome(), "SUCCEEDED");
EXPECT_EQ(fixture.mission_.reportCountFor(22), 1u);
}
TEST(ControlLoopPreempt, NewGoalDuringRecoveryCancelsTheRunningBehavior)
{
ControlLoopConfig config = baseConfig();
config.state_machine.planner_patience = 0.05; // [s] vào recovery nhanh
Fixture fixture(config);
fixture.planner_.setScript({PlannerScript::kFail});
fixture.recovery_.setScript({RecoveryScript::kRunning});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0), reason)) << reason;
for (int i = 0; i < 6 && fixture.loop_.state() != NavigationState::kRecovering; ++i)
{
fixture.stepOnce();
}
ASSERT_EQ(fixture.loop_.state(), NavigationState::kRecovering) << join(fixture.states());
fixture.planner_.setScript({PlannerScript::kOk});
ASSERT_TRUE(fixture.loop_.submit(makeRequest(9.0), reason)) << reason;
fixture.stepOnce();
EXPECT_EQ(fixture.loop_.state(), NavigationState::kPlanning);
EXPECT_GE(fixture.recovery_.cancelCount(), 1u) << "behavior đang chạy không được bảo dừng";
}
int main(int argc, char** argv)
{
testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}