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

862 lines
32 KiB
C++

/*********************************************************************
*
* Software License Agreement (BSD License)
*
* move_base2 — test facade `NavigationServer`: đường lệnh vận tốc ra host, và đường dữ liệu cảm
* biến từ host vào costmap.
*
* Author: DuongTD
*********************************************************************/
#include <gtest/gtest.h>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstddef>
#include <limits>
#include <string>
#include <thread>
#include <vector>
#include <robot_costmap_2d/layered_costmap.h>
#include <move_base2/navigation_server.h>
#include "fake_ports.h"
#include "spy_layer.h"
using move_base2::ControlLoopConfig;
using move_base2::ControlLoopDeps;
using move_base2::MotionProfile;
using move_base2::NavigationRequest;
using move_base2::NavigationServer;
using move_base2::NavigationState;
using move_base2::SensorGatewayConfig;
using move_base2::testing::attachSpy;
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::SpyPtr;
using robot_costmap_2d::LayerType;
namespace
{
constexpr double kControlPeriod = 0.05; ///< [s]
constexpr double kClockStart = 1000.0; ///< [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 không vướng ramp
config.velocity.max_accel_theta = 100.0; // [rad/s^2]
config.nominal_control_period = kControlPeriod;
config.robot_base_frame = "base_link";
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.go_straight = config.position;
config.rotate = config.position;
return config;
}
NavigationRequest makeRequest(double goal_x)
{
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;
return request;
}
robot_geometry_msgs::Vector3 makeVector(double x, double y = 0.0, double z = 0.0)
{
robot_geometry_msgs::Vector3 v;
v.x = x;
v.y = y;
v.z = z;
return v;
}
robot_nav_msgs::Odometry makeOdometry(double vx, double wz)
{
robot_nav_msgs::Odometry odom;
odom.header.frame_id = "odom";
odom.twist.twist.linear.x = vx; // [m/s]
odom.twist.twist.angular.z = wz; // [rad/s]
return odom;
}
robot_sensor_msgs::LaserScan makeScan(std::size_t rays = 40, float range = 1.0F)
{
robot_sensor_msgs::LaserScan scan;
scan.header.frame_id = "laser";
scan.angle_min = -1.5F; // [rad]
scan.angle_max = 1.5F; // [rad]
scan.angle_increment = 3.0F / static_cast<float>(rays); // [rad]
scan.range_min = 0.05F; // [m]
scan.range_max = 10.0F; // [m]
scan.ranges.assign(rays, range);
return scan;
}
/**
* @class Fixture
* @brief `NavigationServer` nối đủ cổng giả, cộng hai costmap thật để kiểm đường cảm biến.
*/
class Fixture
{
public:
Fixture()
: clock_(kClockStart)
, recovery_(2)
, global_("map", false, true)
, local_("odom", true, false)
{
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_;
}
void configure(const ControlLoopConfig& config = baseConfig())
{
std::string error;
ASSERT_TRUE(server_.configureLoop(config, deps_, error)) << error;
}
void configureSensors(const SensorGatewayConfig& config)
{
std::string error;
ASSERT_TRUE(server_.configureSensors(config, error)) << error;
}
/// @brief Chạy @p cycles control cycle, mỗi cycle nhích đồng hồ giả một chu kỳ.
void spin(std::size_t cycles)
{
for (std::size_t i = 0; i < cycles; ++i)
{
server_.spinOnce();
clock_.advance(kControlPeriod);
}
}
void attachCostmaps()
{
server_.attachCostmaps(&global_, &local_);
}
NavigationServer server_;
FakeClockPort clock_;
FakePosePort pose_;
FakePlannerPort planner_;
FakeControllerPort controller_;
FakeRecoveryPort recovery_;
FakeMissionPort mission_;
FakeActionPort action_;
ControlLoopDeps deps_;
robot_costmap_2d::LayeredCostmap global_;
robot_costmap_2d::LayeredCostmap local_;
};
} // namespace
// ================================================================================================
// getTwist() — LỆNH vận tốc, không phải vận tốc đo được
//
// Host lấy getTwist() rồi publish thẳng ra /cmd_vel. Nếu giá trị đó đến từ odometry thì có một vòng
// lặp dương: robot chạy 0.5 m/s -> đọc odom 0.5 -> phát lệnh 0.5 -> mãi mãi. VelocityArbiter — toàn
// bộ hàng rào an toàn của gói — cũng bị bỏ qua hoàn toàn. Các test dưới đây khoá lại điều đó.
// ================================================================================================
TEST(NavigationServerTwist, ReturnsArbiterCommandNotOdometryVelocity)
{
Fixture fixture;
fixture.configure();
fixture.controller_.setNominalSpeed(0.3); // [m/s]
fixture.controller_.setScript({ ControllerScript::kOk, ControllerScript::kOk });
// Odometry báo robot đang chạy nhanh hơn hẳn lệnh mà controller muốn phát.
fixture.server_.addOdometry("/odom", makeOdometry(1.7, 0.9));
ASSERT_TRUE(fixture.server_.moveTo(makeRequest(3.0).goal, 0.15, 0.10))
<< fixture.server_.lastRejectReason();
fixture.spin(2); // IDLE -> PLANNING -> CONTROLLING (controller chạy ngay ở cycle này)
ASSERT_EQ(fixture.server_.loop().state(), NavigationState::kControlling);
const robot_nav_2d_msgs::Twist2DStamped twist = fixture.server_.getTwist();
EXPECT_NEAR(twist.velocity.x, 0.3, 1e-9) << "getTwist trả vận tốc đo được thay vì lệnh đã phát";
EXPECT_NEAR(twist.velocity.theta, 0.0, 1e-9);
}
TEST(NavigationServerTwist, OdometryAloneNeverProducesACommand)
{
Fixture fixture;
fixture.configure();
fixture.server_.addOdometry("/odom", makeOdometry(1.7, 0.9));
fixture.spin(1); // IDLE, không có yêu cầu nào
const robot_nav_2d_msgs::Twist2DStamped twist = fixture.server_.getTwist();
EXPECT_NEAR(twist.velocity.x, 0.0, 1e-9);
EXPECT_NEAR(twist.velocity.y, 0.0, 1e-9);
EXPECT_NEAR(twist.velocity.theta, 0.0, 1e-9);
}
TEST(NavigationServerTwist, StampComesFromTheControlLoopClockNotWallClock)
{
// Host loại lệnh quá hạn theo dấu này. Lấy giờ hệ thống lúc host hỏi sẽ làm một control loop đã
// treo vẫn trông như đang phát lệnh tươi — đúng thứ dấu thời gian sinh ra để ngăn.
// Stamp chỉ tiến khi đang có yêu cầu (xem test StampFreezesWhenIdle...), nên phải có goal chạy.
Fixture fixture;
fixture.configure();
fixture.controller_.setNominalSpeed(0.3); // [m/s]
fixture.controller_.setScript({ ControllerScript::kOk, ControllerScript::kOk });
ASSERT_TRUE(fixture.server_.moveTo(makeRequest(3.0).goal, 0.15, 0.10))
<< fixture.server_.lastRejectReason();
fixture.spin(1);
EXPECT_NEAR(fixture.server_.getTwist().header.stamp.toSec(), kClockStart, 1e-9);
fixture.clock_.setTime(kClockStart + 12.0);
fixture.spin(1);
EXPECT_NEAR(fixture.server_.getTwist().header.stamp.toSec(), kClockStart + 12.0, 1e-9);
}
TEST(NavigationServerTwist, StampFreezesWhenIdleSoTeleopOwnsCmdVel)
{
// Không có yêu cầu nào thì stamp phải ĐỨNG YÊN dù control loop vẫn chạy: host publish /cmd_vel
// qua cửa tươi 0.05 s (amr_publiser.cpp:361), stamp tươi mỗi cycle nghĩa là amr_node phát 0 ở
// 20 Hz vĩnh viễn và đè chết teleop/joystick (rqt_robot_steering 10 Hz — robot chỉ nhích rồi
// đứng im). Bản cũ chỉ đóng dấu trong executeCycle; đây là regression đã gặp trên sim.
Fixture fixture;
fixture.configure();
fixture.spin(3);
EXPECT_TRUE(fixture.server_.getTwist().header.stamp.isZero())
<< "chưa từng có yêu cầu mà stamp đã tươi — host sẽ phát 0 đè teleop";
}
TEST(NavigationServerTwist, StampKeepsFreshBrieflyAfterGoalEndsThenFreezes)
{
// Lệnh 0 cuối cùng phải qua được cửa 0.05 s của host — kết thúc mà đóng băng stamp ngay thì
// robot giữ nguyên vận tốc chót. Cửa ân hạn 0.5 s; hết ân hạn stamp phải đứng yên trả /cmd_vel
// cho teleop.
Fixture fixture;
fixture.configure();
fixture.controller_.setNominalSpeed(0.3); // [m/s]
fixture.controller_.setScript({ ControllerScript::kOk, ControllerScript::kGoalReached });
ASSERT_TRUE(fixture.server_.moveTo(makeRequest(3.0).goal, 0.15, 0.10))
<< fixture.server_.lastRejectReason();
fixture.spin(4); // IDLE -> PLANNING -> CONTROLLING -> tới đích (terminal)
ASSERT_FALSE(fixture.server_.loop().hasActiveRequest());
// Ngay sau khi kết thúc: còn trong ân hạn, stamp vẫn tiến để host phát lệnh dừng.
const double stamp_in_grace = fixture.server_.getTwist().header.stamp.toSec();
fixture.spin(1);
EXPECT_GT(fixture.server_.getTwist().header.stamp.toSec(), stamp_in_grace)
<< "stamp đóng băng ngay khi kết thúc — lệnh dừng cuối không bao giờ được publish";
// Chạy qua hết cửa ân hạn (0.5 s = 10 cycle) rồi thêm vài cycle: stamp phải đứng yên.
fixture.spin(12);
const double stamp_frozen = fixture.server_.getTwist().header.stamp.toSec();
fixture.spin(3);
EXPECT_NEAR(fixture.server_.getTwist().header.stamp.toSec(), stamp_frozen, 1e-9)
<< "hết ân hạn mà stamp vẫn tươi — teleop không bao giờ lấy lại được /cmd_vel";
}
TEST(NavigationServerTwist, StampStaysStillWhenTheControlLoopStopsRunning)
{
Fixture fixture;
fixture.configure();
fixture.spin(1);
const double stamp_after_first = fixture.server_.getTwist().header.stamp.toSec();
// Đồng hồ chạy tiếp nhưng KHÔNG có cycle nào — mô phỏng control thread treo.
fixture.clock_.setTime(kClockStart + 30.0);
fixture.server_.addOdometry("/odom", makeOdometry(1.7, 0.0));
EXPECT_NEAR(fixture.server_.getTwist().header.stamp.toSec(), stamp_after_first, 1e-9)
<< "dấu thời gian tự tươi lại dù control loop không chạy — host sẽ tưởng lệnh còn hiệu lực";
}
TEST(NavigationServerTwist, IsStampedWithTheConfiguredRobotBaseFrame)
{
ControlLoopConfig config = baseConfig();
config.robot_base_frame = "base_footprint";
Fixture fixture;
fixture.configure(config);
fixture.spin(1);
EXPECT_EQ(fixture.server_.getTwist().header.frame_id, "base_footprint");
}
TEST(NavigationServerTwist, ConfigureIsRefusedWhenRobotBaseFrameIsEmpty)
{
ControlLoopConfig config = baseConfig();
config.robot_base_frame.clear();
Fixture fixture;
std::string error;
EXPECT_FALSE(fixture.server_.configureLoop(config, fixture.deps_, error));
EXPECT_FALSE(error.empty());
}
// ================================================================================================
// Đường dữ liệu cảm biến từ host vào costmap
// ================================================================================================
TEST(NavigationServerSensors, SamplesReachTheCostmapLayersOnceAttached)
{
Fixture fixture;
fixture.configure();
SpyPtr static_layer = attachSpy(fixture.global_, LayerType::STATIC_LAYER, "navigation_map");
SpyPtr local_voxel = attachSpy(fixture.local_, LayerType::VOXEL_LAYER, "obstacles");
fixture.attachCostmaps();
fixture.server_.addStaticMap("/map", robot_nav_msgs::OccupancyGrid());
fixture.server_.addLaserScan("/b_scan", makeScan());
fixture.server_.addPointCloud2("/camera/depth/points_proc", robot_sensor_msgs::PointCloud2());
EXPECT_EQ(static_layer->count(), 1U);
EXPECT_EQ(local_voxel->count(), 2U) << "laser + pointcloud2 phải cùng tới VoxelLayer";
EXPECT_EQ(local_voxel->records()[0].topic, "/b_scan");
EXPECT_EQ(local_voxel->records()[1].topic, "/camera/depth/points_proc");
}
TEST(NavigationServerSensors, StoringStillWorksWhenNoCostmapIsAttachedYet)
{
// Trạng thái bình thường lúc khởi động: host đã bắt đầu bơm dữ liệu trước khi costmap được dựng.
// Dữ liệu vẫn phải đọc lại được qua getter của contract host, và số mẫu mất phải đếm được.
Fixture fixture;
fixture.configure();
fixture.server_.addLaserScan("/b_scan", makeScan());
EXPECT_EQ(fixture.server_.getLaserScan("/b_scan").ranges.size(), 40U);
EXPECT_EQ(fixture.server_.sensors().stats().dropped_no_costmap, 1U);
EXPECT_EQ(fixture.server_.sensors().stats().delivered, 0U);
}
TEST(NavigationServerSensors, StaticMapReceivedBeforeAttachIsReplayed)
{
// Không có phần phát lại này thì thứ tự "map tới trước, costmap dựng sau" — thứ tự thường gặp
// nhất khi khởi động — để global costmap trắng vĩnh viễn: /map là topic latched, host không gửi
// lại. Bản cũ bù bằng cặp biến public map_save_/map_name_save_.
Fixture fixture;
fixture.configure();
fixture.server_.addStaticMap("/map", robot_nav_msgs::OccupancyGrid());
ASSERT_EQ(fixture.server_.sensors().stats().dropped_no_costmap, 1U);
SpyPtr static_layer = attachSpy(fixture.global_, LayerType::STATIC_LAYER, "navigation_map");
fixture.attachCostmaps();
ASSERT_EQ(static_layer->count(), 1U) << "static map nhận trước khi gắn costmap không được phát lại";
EXPECT_EQ(static_layer->records()[0].topic, "/map");
}
TEST(NavigationServerSensors, LegacyMapSavePublicMemberIsAlsoReplayed)
{
// `map_save_`/`map_name_save_` là biến PUBLIC của BaseNavigation mà host tự gán
// (sensor_converter.cpp). Giữ đường này để host không phải sửa gì khi đổi sang move_base2.
Fixture fixture;
fixture.configure();
fixture.server_.map_name_save_ = "/map";
fixture.server_.map_save_ = robot_nav_msgs::OccupancyGrid();
SpyPtr static_layer = attachSpy(fixture.global_, LayerType::STATIC_LAYER, "navigation_map");
fixture.attachCostmaps();
EXPECT_EQ(static_layer->count(), 1U);
EXPECT_EQ(static_layer->records()[0].topic, "/map");
}
TEST(NavigationServerSensors, ReplayDoesNotDuplicateAMapAlreadyReceivedThroughTheApi)
{
Fixture fixture;
fixture.configure();
fixture.server_.addStaticMap("/map", robot_nav_msgs::OccupancyGrid());
fixture.server_.map_name_save_ = "/map"; // host gán cả hai đường, như bản cũ đang làm
fixture.server_.map_save_ = robot_nav_msgs::OccupancyGrid();
SpyPtr static_layer = attachSpy(fixture.global_, LayerType::STATIC_LAYER, "navigation_map");
fixture.attachCostmaps();
EXPECT_EQ(static_layer->count(), 1U) << "cùng một map bị phát lại hai lần";
}
TEST(NavigationServerSensors, StaleLaserScansAreNotReplayedOnAttach)
{
// Cố ý: phát lại một scan cũ là dựng vật cản ở chỗ robot có thể đã rời khỏi từ lâu. Mẫu kế tiếp
// chỉ cách vài chục ms — chờ nó an toàn hơn hẳn.
Fixture fixture;
fixture.configure();
fixture.server_.addLaserScan("/b_scan", makeScan());
SpyPtr voxel = attachSpy(fixture.local_, LayerType::VOXEL_LAYER, "obstacles");
fixture.attachCostmaps();
EXPECT_EQ(voxel->count(), 0U);
}
TEST(NavigationServerSensors, StoredLaserScanIsTheSameOneHandedToTheCostmap)
{
// Bản cũ cất bản ĐÃ LỌC. Nếu getter trả bản thô còn costmap thấy bản lọc thì hai nguồn sự thật
// lệch nhau, và mọi chẩn đoán dựa trên getter sẽ nói dối về thứ costmap thật sự dùng.
SensorGatewayConfig sensors;
sensors.laser_sor_enabled = true;
sensors.laser_sor_mean_k = 5;
sensors.laser_sor_stddev_mul = 1.0;
Fixture fixture;
fixture.configure();
fixture.configureSensors(sensors);
std::vector<float> seen_by_layer;
SpyPtr voxel = attachSpy(fixture.local_, LayerType::VOXEL_LAYER, "obstacles");
voxel->setObserver([&seen_by_layer](const void* data, const std::type_info& type,
const std::string&) {
if (type == typeid(robot_sensor_msgs::LaserScan))
{
seen_by_layer = static_cast<const robot_sensor_msgs::LaserScan*>(data)->ranges;
}
});
fixture.attachCostmaps();
fixture.server_.addLaserScan("/b_scan", makeScan());
const std::vector<float> stored = fixture.server_.getLaserScan("/b_scan").ranges;
ASSERT_FALSE(seen_by_layer.empty());
ASSERT_EQ(stored.size(), seen_by_layer.size());
// So từng phần tử chứ không so cả vector: bộ lọc biến outlier thành NaN để giữ nguyên cấu trúc
// scan, mà NaN != NaN nên operator== của vector sẽ báo khác nhau dù nội dung giống hệt.
for (std::size_t i = 0; i < stored.size(); ++i)
{
if (std::isnan(stored[i]))
{
EXPECT_TRUE(std::isnan(seen_by_layer[i])) << "lệch tại tia " << i;
}
else
{
EXPECT_FLOAT_EQ(stored[i], seen_by_layer[i]) << "lệch tại tia " << i;
}
}
}
TEST(NavigationServerSensors, DepthCameraDataIsStoredAndForwardedAsConstPtr)
{
Fixture fixture;
fixture.configure();
SpyPtr voxel = attachSpy(fixture.local_, LayerType::VOXEL_LAYER, "obstacles");
fixture.attachCostmaps();
robot_sensor_msgs::DepthCameraData::Ptr data =
boost::make_shared<robot_sensor_msgs::DepthCameraData>();
data->header.frame_id = "camera_optical";
fixture.server_.addDepthCameraData("/camera/depth/data", data);
ASSERT_EQ(voxel->count(), 1U);
EXPECT_TRUE(*voxel->records()[0].type == typeid(robot_sensor_msgs::DepthCameraData::ConstPtr));
EXPECT_EQ(voxel->records()[0].topic, "/camera/depth/data");
}
TEST(NavigationServerSensors, NullDepthPointerIsRejectedAtTheDoor)
{
Fixture fixture;
fixture.configure();
SpyPtr voxel = attachSpy(fixture.local_, LayerType::VOXEL_LAYER, "obstacles");
fixture.attachCostmaps();
fixture.server_.addDepthCameraData("/camera/depth/data",
robot_sensor_msgs::DepthCameraData::ConstPtr());
EXPECT_EQ(voxel->count(), 0U);
EXPECT_EQ(fixture.server_.sensors().stats().dropped_no_costmap, 0U);
}
// ================================================================================================
// Trần vận tốc (bước 12) — đường tầng an toàn hạ tốc độ robot
//
// `setTwistLinear` không phải lệnh jog dù tên nghe như vậy: host gọi nó theo cặp +v/-v để đặt trần
// cho hai chiều, và giá trị truyền xuống mang theo tốc độ đã bị tầng an toàn hạ
// (amr_control.cpp:561, 671-680). Trước đây `NavigationServer` trả false và không làm gì.
// ================================================================================================
TEST(NavigationServerLimits, ForwardAndBackwardLimitsReachTheController)
{
Fixture fixture;
fixture.configure();
EXPECT_TRUE(fixture.server_.setTwistLinear(makeVector(0.30))); // [m/s] trần tiến
EXPECT_TRUE(fixture.server_.setTwistLinear(makeVector(-0.15))); // [m/s] trần lùi, ÂM
fixture.spin(1);
EXPECT_NEAR(fixture.controller_.limitForward(), 0.30, 1e-9);
EXPECT_NEAR(fixture.controller_.limitBackward(), -0.15, 1e-9);
}
TEST(NavigationServerLimits, AngularLimitReachesTheController)
{
Fixture fixture;
fixture.configure();
EXPECT_TRUE(fixture.server_.setTwistAngular(makeVector(0.0, 0.0, 0.45))); // [rad/s]
fixture.spin(1);
EXPECT_NEAR(fixture.controller_.limitAngular(), 0.45, 1e-9);
}
TEST(NavigationServerLimits, LimitTakesEffectInTheSameCycleItIsPushed)
{
// Chậm một cycle nghĩa là một chu kỳ nữa robot chạy quá tốc độ mà tầng an toàn vừa yêu cầu hạ.
Fixture fixture;
fixture.configure();
ASSERT_TRUE(fixture.server_.setTwistLinear(makeVector(0.12)));
fixture.spin(1);
EXPECT_NEAR(fixture.controller_.limitForward(), 0.12, 1e-9);
}
TEST(NavigationServerLimits, NonFiniteLimitIsRejectedAtTheDoor)
{
Fixture fixture;
fixture.configure();
const double nan = std::numeric_limits<double>::quiet_NaN();
EXPECT_FALSE(fixture.server_.setTwistLinear(makeVector(nan)));
EXPECT_FALSE(fixture.server_.setTwistAngular(makeVector(0.0, 0.0, nan)));
fixture.spin(1);
EXPECT_NEAR(fixture.controller_.limitForward(), 0.0, 1e-9);
}
TEST(NavigationServerLimits, LatestLimitWinsWhenSetSeveralTimesWithinOneCycle)
{
// Host gọi từ thread của nó với nhịp riêng; nhiều lời gọi giữa hai cycle là bình thường. Thứ phải
// có hiệu lực là giá trị MỚI NHẤT, không phải giá trị đầu tiên.
Fixture fixture;
fixture.configure();
ASSERT_TRUE(fixture.server_.setTwistLinear(makeVector(0.40)));
ASSERT_TRUE(fixture.server_.setTwistLinear(makeVector(0.10))); // tầng an toàn vừa hạ tiếp
fixture.spin(1);
EXPECT_NEAR(fixture.controller_.limitForward(), 0.10, 1e-9);
}
TEST(NavigationServerLimits, OdometryReachesTheControllerAsMeasuredVelocity)
{
// Bản cũ đưa vận tốc đo được vào controller bằng con trỏ tới bộ nhớ host ghi
// (`tc_->setOdom(&odometry_)`) — data race không có gì bảo vệ. Ở đây truyền theo giá trị, qua
// control thread.
Fixture fixture;
fixture.configure();
fixture.server_.addOdometry("/odom", makeOdometry(0.42, -0.17));
fixture.spin(1);
EXPECT_NEAR(fixture.controller_.measuredVelocity().linear.x, 0.42, 1e-9);
EXPECT_NEAR(fixture.controller_.measuredVelocity().angular.z, -0.17, 1e-9);
}
// ================================================================================================
// pause / resume / cancel — host gọi từ thread khác
//
// OPC-UA và VDA5050 chạy thread riêng (amr_control.cpp:159, 184) và gọi thẳng ba hàm này.
// `ControlLoop` tự khai là không thread-safe, và `requestPause()` còn ghi HAI cờ không nguyên tử —
// xen kẽ với `requestResume()` có thể để lại cả hai cùng false. Nên chúng chỉ được ghi nhận ở đây,
// rồi chuyển xuống lõi trên control thread.
// ================================================================================================
TEST(NavigationServerLifecycle, PauseTakesEffectOnTheNextCycleNotImmediately)
{
Fixture fixture;
fixture.configure();
fixture.controller_.setScript({ ControllerScript::kOk, ControllerScript::kOk });
ASSERT_TRUE(fixture.server_.moveTo(makeRequest(3.0).goal, 0.15, 0.10))
<< fixture.server_.lastRejectReason();
fixture.spin(2);
ASSERT_EQ(fixture.server_.loop().state(), NavigationState::kControlling);
fixture.server_.pause();
EXPECT_EQ(fixture.server_.loop().state(), NavigationState::kControlling)
<< "pause() đi thẳng vào lõi từ thread host";
fixture.spin(1);
EXPECT_EQ(fixture.server_.loop().state(), NavigationState::kPaused);
}
TEST(NavigationServerLifecycle, ResumeAfterPauseReturnsToControlling)
{
Fixture fixture;
fixture.configure();
fixture.controller_.setScript({ ControllerScript::kOk });
ASSERT_TRUE(fixture.server_.moveTo(makeRequest(3.0).goal, 0.15, 0.10))
<< fixture.server_.lastRejectReason();
fixture.spin(2);
fixture.server_.pause();
fixture.spin(1);
ASSERT_EQ(fixture.server_.loop().state(), NavigationState::kPaused);
fixture.server_.resume();
fixture.spin(1);
EXPECT_EQ(fixture.server_.loop().state(), NavigationState::kControlling);
}
TEST(NavigationServerLifecycle, PauseThenResumeWithinOneCycleEndsResumed)
{
// Hai cờ đối nghịch được đặt dưới cùng một lock, nên lệnh sau luôn thắng lệnh trước — không có
// trạng thái "cả hai cùng false" như bản ghi hai cờ rời rạc.
Fixture fixture;
fixture.configure();
fixture.controller_.setScript({ ControllerScript::kOk });
ASSERT_TRUE(fixture.server_.moveTo(makeRequest(3.0).goal, 0.15, 0.10))
<< fixture.server_.lastRejectReason();
fixture.spin(2);
ASSERT_EQ(fixture.server_.loop().state(), NavigationState::kControlling);
fixture.server_.pause();
fixture.server_.resume();
fixture.spin(1);
EXPECT_EQ(fixture.server_.loop().state(), NavigationState::kControlling);
}
TEST(NavigationServerLifecycle, CancelWinsOverAPauseRequestedInTheSameCycle)
{
// "Tạm dừng rồi huỷ" và "huỷ rồi tạm dừng" phải cho cùng kết quả: huỷ thắng.
Fixture fixture;
fixture.configure();
fixture.controller_.setScript({ ControllerScript::kOk });
ASSERT_TRUE(fixture.server_.moveTo(makeRequest(3.0).goal, 0.15, 0.10))
<< fixture.server_.lastRejectReason();
fixture.spin(2);
ASSERT_EQ(fixture.server_.loop().state(), NavigationState::kControlling);
fixture.server_.pause();
fixture.server_.cancel();
fixture.spin(1);
EXPECT_NE(fixture.server_.loop().state(), NavigationState::kPaused) << "pause thắng cancel";
}
TEST(NavigationServerLifecycle, LifecycleRequestIsConsumedExactlyOnce)
{
Fixture fixture;
fixture.configure();
fixture.controller_.setScript({ ControllerScript::kOk });
ASSERT_TRUE(fixture.server_.moveTo(makeRequest(3.0).goal, 0.15, 0.10))
<< fixture.server_.lastRejectReason();
fixture.spin(2);
fixture.server_.pause();
fixture.spin(1);
ASSERT_EQ(fixture.server_.loop().state(), NavigationState::kPaused);
// Không có lệnh mới: cờ đã bị tiêu thụ, các cycle sau không được tự tạm dừng lại lần nữa.
fixture.server_.resume();
fixture.spin(3);
EXPECT_NE(fixture.server_.loop().state(), NavigationState::kPaused);
}
// ================================================================================================
// Control thread
//
// Contract `BaseNavigation` KHÔNG có hàm spin nào: host nạp plugin, gọi initialize(), rồi chỉ tương
// tác qua moveTo/getTwist/getFeedback. Runtime vì thế phải TỰ LÁI mình, đúng như bản cũ (thread
// planner + action server). Thiếu control thread thì goal nằm im trong chỗ chờ vĩnh viễn — không
// cycle nào chạy, không state nào đổi, và không log gì cả. Đó là lỗi đã thật sự xảy ra trên sim.
// ================================================================================================
TEST(NavigationServerControlThread, RunsCyclesWithoutAnyoneCallingSpinOnce)
{
Fixture fixture;
fixture.configure();
fixture.controller_.setScript({ ControllerScript::kOk });
ASSERT_TRUE(fixture.server_.moveTo(makeRequest(3.0).goal, 0.15, 0.10))
<< fixture.server_.lastRejectReason();
// Ở đây KHÔNG gọi spin() của fixture: chính control thread phải đẩy state đi.
ASSERT_TRUE(fixture.server_.startControlThread(200.0)); // [Hz]
bool left_idle = false;
for (int i = 0; i < 500 && !left_idle; ++i)
{
std::this_thread::sleep_for(std::chrono::milliseconds(2));
left_idle = fixture.server_.loop().state() != NavigationState::kIdle;
}
fixture.server_.stopControlThread();
EXPECT_TRUE(left_idle) << "goal được nhận nhưng không cycle nào chạy — thiếu control thread";
}
TEST(NavigationServerControlThread, RefusesToStartBeforeTheLoopIsConfigured)
{
NavigationServer server;
EXPECT_FALSE(server.startControlThread(20.0));
EXPECT_FALSE(server.controlThreadRunning());
}
TEST(NavigationServerControlThread, RefusesNonPositiveFrequency)
{
Fixture fixture;
fixture.configure();
EXPECT_FALSE(fixture.server_.startControlThread(0.0));
EXPECT_FALSE(fixture.server_.startControlThread(-5.0));
}
TEST(NavigationServerControlThread, SecondStartIsRefusedAndStopIsIdempotent)
{
Fixture fixture;
fixture.configure();
ASSERT_TRUE(fixture.server_.startControlThread(100.0));
EXPECT_FALSE(fixture.server_.startControlThread(100.0)) << "khởi động thread thứ hai";
fixture.server_.stopControlThread();
fixture.server_.stopControlThread(); // không được treo hay sập
EXPECT_FALSE(fixture.server_.controlThreadRunning());
}
TEST(NavigationServerControlThread, DestructorStopsTheThread)
{
// Thread chạm loop_, runtime_ và sensors_ mỗi cycle; huỷ chúng khi thread còn sống là hỏng ở chỗ
// không truy được.
Fixture fixture;
fixture.configure();
ASSERT_TRUE(fixture.server_.startControlThread(100.0));
SUCCEED(); // destructor của fixture phải join, không treo
}
// ================================================================================================
// Dữ liệu hiển thị — host gọi từ BỐN ros::Timer khác nhau
// ================================================================================================
TEST(NavigationServerPlannerData, GettersDoNotShareMutableState)
{
// `getGlobalData` và `getLocalData` mỗi cái được gọi từ HAI timer (costmap và plan). Ghi vào
// `global_data_`/`local_data_` dùng chung đã gây `std::bad_alloc` rồi hỏng heap và giết tiến trình.
Fixture fixture;
fixture.configure();
fixture.spin(1);
robot::move_base_core::PlannerDataOutput a = fixture.server_.getGlobalData();
robot::move_base_core::PlannerDataOutput b = fixture.server_.getGlobalData();
a.plan.poses.clear();
EXPECT_TRUE(b.plan.poses.empty() || !a.plan.poses.empty())
<< "hai lần gọi trả về cùng một vùng nhớ";
EXPECT_NO_THROW({ (void)fixture.server_.getLocalData(); });
}
TEST(NavigationServerPlannerData, ConcurrentGettersDoNotCorruptEachOther)
{
// Không chứng minh được không có race (cần ThreadSanitizer), nhưng chạy đúng hình dạng lời gọi
// của host: bốn thread cùng đọc trong khi control thread cùng ghi bộ đệm.
Fixture fixture;
fixture.configure();
fixture.controller_.setScript({ ControllerScript::kOk });
ASSERT_TRUE(fixture.server_.moveTo(makeRequest(3.0).goal, 0.15, 0.10))
<< fixture.server_.lastRejectReason();
ASSERT_TRUE(fixture.server_.startControlThread(200.0));
std::atomic<bool> stop{ false };
std::vector<std::thread> readers;
for (int i = 0; i < 4; ++i)
{
readers.emplace_back([&fixture, &stop, i]() {
while (!stop.load())
{
if (i % 2 == 0)
{
(void)fixture.server_.getGlobalData();
}
else
{
(void)fixture.server_.getLocalData();
}
}
});
}
std::this_thread::sleep_for(std::chrono::milliseconds(150));
stop.store(true);
for (auto& t : readers)
{
t.join();
}
fixture.server_.stopControlThread();
SUCCEED();
}
TEST(NavigationServerPlannerData, PlanIsStampedWithTheControlLoopClock)
{
Fixture fixture;
fixture.configure();
fixture.clock_.setTime(kClockStart + 7.0);
fixture.spin(1);
EXPECT_NEAR(fixture.server_.getGlobalData().plan.header.stamp.toSec(), kClockStart + 7.0, 1e-9)
<< "plan mang dấu thời gian khác đồng hồ control loop — host sẽ coi là quá hạn và bỏ qua";
}
int main(int argc, char** argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}