optimal & fix file cmake

This commit is contained in:
2026-08-03 22:41:32 +07:00
parent d8babff20b
commit 701d25f952
70 changed files with 5572 additions and 1146 deletions

View File

@@ -13,13 +13,14 @@
#include <robot/node_handle.h>
#include <action_core/action_handler.h>
#include <move_base2/runners/action_runner.h>
#include "fake_ports.h"
namespace
{
using move_base2::ActionHandler;
using action_core::ActionHandler;
using move_base2::ActionRunner;
using move_base2::ActionTick;
using move_base2::testing::FakeClockPort;
@@ -45,7 +46,8 @@ public:
{
}
bool configure(const std::string& name, robot::NodeHandle& /*nh*/) override
bool configure(const std::string& name, const action_core::ActionContext& /*ctx*/,
robot::NodeHandle& /*nh*/) override
{
name_ = name;
return configure_ok;
@@ -63,10 +65,10 @@ public:
return start_ok;
}
ActionTick update(const robot::Time& /*now*/) override
action_core::ActionTick update(const robot::Time& /*now*/) override
{
++update_count;
ActionTick tick;
action_core::ActionTick tick;
tick.status = next_status;
return tick;
}
@@ -78,7 +80,7 @@ public:
bool configure_ok = true;
bool start_ok = true;
ActionTick::Status next_status = ActionTick::Status::kRunning;
action_core::ActionStatus next_status = action_core::ActionStatus::kRunning;
int start_count = 0;
int update_count = 0;
@@ -224,7 +226,7 @@ TEST(ActionRunner, TicksUntilHandlerFinishes)
EXPECT_EQ(rig.runner.update().status, ActionTick::Status::kRunning);
EXPECT_EQ(rig.runner.update().status, ActionTick::Status::kRunning);
handler->next_status = ActionTick::Status::kSucceeded;
handler->next_status = action_core::ActionStatus::kSucceeded;
EXPECT_EQ(rig.runner.update().status, ActionTick::Status::kSucceeded);
// Sau khi kết thúc, action không còn active: tick thêm là lỗi thứ tự gọi, không phải kRunning.
@@ -258,7 +260,8 @@ TEST(ActionRunner, ConfigureRequiresClock)
{
ActionRunner runner; // không setClock
robot::NodeHandle nh;
EXPECT_FALSE(runner.configure(nh)) << "thiếu ClockPort thì handler không có mốc timeout";
EXPECT_FALSE(runner.configure(nh)) << "missing ClockPort means handlers have no timeout "
"reference";
}
TEST(ActionRunner, EmptyHandlerListIsValid)

View File

@@ -4,6 +4,7 @@
# --- Tham số runtime, dùng cho config_validation_test ----------------------------------------
move_base2:
docking_requires_marker: false
controller_frequency: 20.0 # [Hz]
planner_frequency: 0.0 # [Hz] 0 = chỉ lập plan khi cần
planner_timeout: 5.0 # [s]
@@ -33,18 +34,53 @@ move_base2:
recovery_namespace: recovery
action_namespace: actions
mission_namespace: mission_adapters
backup_global_planner: TestBackupGlobalPlanner
position:
base_global_planner: TestGlobalPlanner
base_local_planner: TestLocalPlanner
xy_goal_tolerance: 0.15 # [m]
yaw_goal_tolerance: 0.10 # [rad]
docking:
base_global_planner: TestDockPlanner
base_local_planner: TestLocalPlanner
xy_goal_tolerance: 0.02 # [m] ghép nối cần chính xác hơn nhiều
yaw_goal_tolerance: 0.02 # [rad]
docking_marker_profiles:
trolley:
global_planner: TestTrolleyDockPlanner
local_planner: TestTrolleyDockLocalPlanner
# --- Schema runtime root-profile ---------------------------------------------------------------
# Đây là schema dùng bởi move_base_common_params.yaml của move_base2. Không có adapter gen-1.
root_profiles:
controller_frequency: 30.0
planner_frequency: 0.0
planner_patience: 2.0
controller_patience: 0.033333333
max_planning_retries: 0
recovery_behavior_enabled: true
docking_requires_marker: false
backup_global_planner: TestBackupGlobalPlanner
position:
global_planner: TestGlobalPlanner
local_planner: TestLocalPlanner
docking:
global_planner: TestDockPlanner
local_planner: TestLocalPlanner
docking_marker_profiles:
trolley:
global_planner: TestTrolleyDockPlanner
local_planner: TestTrolleyDockLocalPlanner
go_straight:
global_planner: TestStraightPlanner
local_planner: TestStraightLocalPlanner
rotate:
global_planner: TestRotatePlanner
local_planner: TestRotateLocalPlanner
# --- Cấu hình sai, dùng cho test đường lỗi -----------------------------------------------------
move_base2_bad_frequency:
@@ -77,6 +113,11 @@ recovery:
- {name: wait_short, type: WaitRecovery}
- {name: wait_long, type: WaitRecovery}
routes:
planning_failed: [wait_short]
controlling_failed: [wait_long, wait_short]
oscillation: [wait_long]
wait_short:
wait_duration: 1.0 # [s]
wait_long:
@@ -90,9 +131,40 @@ recovery_missing_library:
behaviors:
- {name: ghost, type: GhostRecovery}
# Mô phỏng config được commit trước plugin DetourPathRecovery. `configure()` báo partial failure,
# nhưng configureRoutes() phải bỏ detour_path thay vì đưa index không tồn tại sang StateMachine.
recovery_missing_detour:
behaviors:
- {name: wait_short, type: WaitRecovery}
- {name: detour_path, type: DetourPathRecovery}
routes:
planning_failed: [wait_short]
controlling_failed: [detour_path, wait_short]
oscillation: [detour_path, wait_short]
# --- Recovery THẬT cho kịch bản có vật cản (RecoveryScenarioDriver) ----------------------------
#
# Namespace riêng, KHÔNG dùng chung với `recovery` ở trên: hai bộ test hỏi hai câu khác nhau. Bộ kia
# kiểm chỗ nối RecoveryPort <-> recovery_core bằng behavior họ kNone; bộ này kiểm hành vi an toàn
# THẬT của BackUpRecovery trên lưới có vật cản.
recovery_scenario:
behaviors:
- {name: back_up, type: BackUpRecovery}
back_up:
backup_distance: 0.50 # [m] quãng lùi mong muốn
backup_distance_max: 1.0 # [m] trần cứng
linear_speed: 0.15 # [m/s] độ lớn; dấu âm do plugin đặt (lùi)
acc_lim_x: 1.0 # [m/s^2]
timeout: 15.0 # [s]
WaitRecovery:
library_path: librecovery_core_wait_recovery
BackUpRecovery:
library_path: librecovery_core_back_up_recovery
# GhostRecovery cố ý KHÔNG khai library_path.
# --- Action handler cho action_runner_test -----------------------------------------------------
@@ -136,7 +208,7 @@ actions_missing_library:
- {name: ghost, type: GhostActionHandler}
NoopActionHandler:
library_path: libmove_base2_noop_action_handler
library_path: libaction_core_noop_action_handler
# --- Global planner giả cho planner_runner_test -------------------------------------------------
#
@@ -167,6 +239,14 @@ TestControllerThrowing:
library_path: libmove_base2_test_local_planner
TestControllerRefusesLimits:
library_path: libmove_base2_test_local_planner
TestControllerMarkerProbe:
library_path: libmove_base2_test_local_planner
TestControllerFootprintProbe:
library_path: libmove_base2_test_local_planner
# Danh sách marker cho test đường docking (setDockingMarker) — format chuỗi cách nhau bằng space,
# đúng như maker_sources.yaml production.
maker_sources: dock_a dock_b
# TestControllerMissing cố ý KHÔNG khai library_path.
@@ -181,9 +261,6 @@ legacy_move_base:
max_planning_retries: 0
recovery_behavior_enabled: true
xy_goal_tolerance: 0.25 # [m] default chung cho cả bốn profile
yaw_goal_tolerance: 0.30 # [rad]
base_global_planner: SBPLLatticePlanner
base_local_planner: LocalPlannerAdapter # phải bị BỎ QUA có log

View File

@@ -113,6 +113,37 @@ TEST(MoveBase2Config, ReadsEveryGroupFromYaml)
EXPECT_EQ(config.global_frame, "map");
EXPECT_EQ(config.robot_base_frame, "base_link");
EXPECT_EQ(config.recovery_namespace, "recovery");
EXPECT_FALSE(config.docking_requires_marker);
}
TEST(MoveBase2Config, MissionLayerIsEnabledByDefault)
{
// Mặc định bật: order VDA5050 đi qua mission layer và được cắt thành chặng. Đổi mặc định này là
// đổi hành vi của mọi order trên robot, nên nó được khoá lại bằng test.
const MoveBase2Config defaults;
EXPECT_TRUE(defaults.mission_layer_enabled);
EXPECT_EQ(defaults.mission_namespace, "mission_adapters");
}
TEST(MoveBase2Config, RejectsEnabledMissionLayerWithoutNamespace)
{
MoveBase2Config config = withRecoveryCount(loadFrom("move_base2"), 2);
config.mission_layer_enabled = true;
config.mission_namespace.clear();
std::string error;
EXPECT_FALSE(config.validate(error));
EXPECT_NE(error.find("mission_namespace"), std::string::npos) << error;
}
TEST(MoveBase2Config, DisabledMissionLayerDoesNotNeedANamespace)
{
MoveBase2Config config = withRecoveryCount(loadFrom("move_base2"), 2);
config.mission_layer_enabled = false;
config.mission_namespace.clear();
std::string error;
EXPECT_TRUE(config.validate(error)) << error;
}
TEST(MoveBase2Config, ReadsProfileBindingsFromNestedNamespaces)
@@ -121,12 +152,37 @@ TEST(MoveBase2Config, ReadsProfileBindingsFromNestedNamespaces)
EXPECT_EQ(config.position.global_planner_name, "TestGlobalPlanner");
EXPECT_EQ(config.position.local_planner_name, "TestLocalPlanner");
EXPECT_DOUBLE_EQ(config.position.default_xy_tolerance, 0.15);
// Ghép nối cần sai số chặt hơn nhiều — đây chính là thứ sáu entry point cũ khác nhau ở.
EXPECT_EQ(config.docking.global_planner_name, "TestDockPlanner");
EXPECT_DOUBLE_EQ(config.docking.default_xy_tolerance, 0.02);
EXPECT_DOUBLE_EQ(config.docking.default_yaw_tolerance, 0.02);
EXPECT_EQ(config.backup_global_planner_name, "TestBackupGlobalPlanner");
ASSERT_EQ(config.docking_marker_profiles.size(), 1U);
const auto trolley = config.docking_marker_profiles.find("trolley");
ASSERT_NE(trolley, config.docking_marker_profiles.end());
EXPECT_EQ(trolley->second.global_planner_name, "TestTrolleyDockPlanner");
EXPECT_EQ(trolley->second.local_planner_name, "TestTrolleyDockLocalPlanner");
}
TEST(MoveBase2ConfigRootProfile, LoadsIndependentPlannerPairsWithoutTheLegacyAdapter)
{
robot::NodeHandle root;
robot::NodeHandle profile_nh(root, "root_profiles");
const MoveBase2Config config = MoveBase2Config::load(profile_nh);
EXPECT_EQ(config.position.global_planner_name, "TestGlobalPlanner");
EXPECT_EQ(config.position.local_planner_name, "TestLocalPlanner");
EXPECT_EQ(config.docking.global_planner_name, "TestDockPlanner");
EXPECT_EQ(config.docking.local_planner_name, "TestLocalPlanner");
EXPECT_EQ(config.go_straight.global_planner_name, "TestStraightPlanner");
EXPECT_EQ(config.go_straight.local_planner_name, "TestStraightLocalPlanner");
EXPECT_EQ(config.rotate.global_planner_name, "TestRotatePlanner");
EXPECT_EQ(config.rotate.local_planner_name, "TestRotateLocalPlanner");
EXPECT_EQ(config.backup_global_planner_name, "TestBackupGlobalPlanner");
ASSERT_EQ(config.docking_marker_profiles.size(), 1U);
const auto trolley = config.docking_marker_profiles.find("trolley");
ASSERT_NE(trolley, config.docking_marker_profiles.end());
EXPECT_EQ(trolley->second.global_planner_name, "TestTrolleyDockPlanner");
EXPECT_EQ(trolley->second.local_planner_name, "TestTrolleyDockLocalPlanner");
EXPECT_FALSE(config.docking_requires_marker);
}
TEST(MoveBase2Config, LoadedConfigValidates)
@@ -170,17 +226,7 @@ TEST(MoveBase2Config, RejectsConfigWithNoLocalPlannerAtAll)
std::string error;
EXPECT_FALSE(config.validate(error))
<< "cấu hình này lúc chạy sẽ từ chối MỌI yêu cầu — phải chặn ngay lúc khởi động";
}
TEST(MoveBase2Config, RejectsNonPositiveToleranceOnConfiguredProfile)
{
MoveBase2Config config = minimalValid();
config.position.default_xy_tolerance = 0.0;
std::string error;
EXPECT_FALSE(config.validate(error));
EXPECT_NE(error.find("xy_goal_tolerance"), std::string::npos);
<< "this config would reject EVERY request at runtime — it must be caught at startup";
}
TEST(MoveBase2Config, PropagatesStateMachineValidationFailure)
@@ -267,9 +313,6 @@ TEST(MoveBase2ConfigLegacy, RootToleranceAppliesToEveryProfile)
MoveBase2Config config;
config.fromLegacyNodeHandle(nh);
EXPECT_DOUBLE_EQ(config.position.default_xy_tolerance, 0.25);
EXPECT_DOUBLE_EQ(config.docking.default_yaw_tolerance, 0.30);
EXPECT_DOUBLE_EQ(config.rotate.default_xy_tolerance, 0.25);
}
TEST(MoveBase2ConfigLegacy, ZeroPatienceBecomesOneControlCycleNotDisabled)
@@ -319,8 +362,10 @@ TEST(MoveBase2ConfigLegacy, AutoDetectPrefersTheModernSchema)
robot::NodeHandle root;
const MoveBase2Config config = MoveBase2Config::load(root);
EXPECT_EQ(config.robot_base_frame, "base_link") << "chọn nhầm schema gen-1 dù có namespace mới";
EXPECT_TRUE(config.sensors.laser_sor_enabled) << "khoá chỉ có ở schema mới không được đọc";
EXPECT_EQ(config.robot_base_frame, "base_link") << "picked the gen-1 schema even though the new "
"namespace exists";
EXPECT_TRUE(config.sensors.laser_sor_enabled) << "a key that only exists in the new schema was "
"not read";
}
TEST(MoveBase2ConfigLegacy, AutoDetectFallsBackToLegacyWhenNoModernNamespace)

View File

@@ -153,7 +153,8 @@ TEST(ControllerRunner, ConfigureFailsWhenTheInitialControllerCannotBeLoaded)
std::string error;
EXPECT_FALSE(runner.configure(nh, nullptr, dummyCostmap(), &fixedPose(), "TestControllerMissing", error));
EXPECT_FALSE(runner.configured()) << "configure thất bại nhưng vẫn tự coi là đã cấu hình";
EXPECT_FALSE(runner.configured()) << "configure failed but the object still reports itself as "
"configured";
}
TEST(ControllerRunner, LoadsTheInitialControllerAndReportsItAsActive)
@@ -177,7 +178,8 @@ TEST(ControllerRunner, SwapsBetweenControllersAndReusesLoadedLibraries)
ASSERT_TRUE(fixture.runner_.swapPlanner("TestControllerOk"));
EXPECT_EQ(fixture.runner_.activeController(), "TestControllerOk");
EXPECT_EQ(fixture.runner_.loadedCount(), 2U) << "quay lại controller cũ mà vẫn nạp lại thư viện";
EXPECT_EQ(fixture.runner_.loadedCount(), 2U) << "switched back to the previous controller yet "
"reloaded the library";
}
TEST(ControllerRunner, FailedSwapKeepsThePreviousControllerActive)
@@ -213,7 +215,7 @@ TEST(ControllerRunner, ForwardVelocityLimitReachesThePlugin)
ASSERT_TRUE(fixture.runner_.setTwistLinear(vec(0.10))); // [m/s]
ASSERT_TRUE(fixture.runner_.computeVelocityCommands(cmd));
EXPECT_NEAR(cmd.linear.x, 0.10, 1e-9) << "trần vận tốc không tới được plugin";
EXPECT_NEAR(cmd.linear.x, 0.10, 1e-9) << "velocity limit never reached the plugin";
}
TEST(ControllerRunner, AngularVelocityLimitReachesThePlugin)
@@ -245,7 +247,7 @@ TEST(ControllerRunner, LimitSetBeforeAControllerExistsIsAppliedOnceItIsLoaded)
robot_geometry_msgs::Twist cmd;
ASSERT_TRUE(runner.computeVelocityCommands(cmd));
EXPECT_NEAR(cmd.linear.x, 0.08, 1e-9) << "trần đặt trước khi nạp controller bị mất";
EXPECT_NEAR(cmd.linear.x, 0.08, 1e-9) << "a limit set before the controller was loaded got lost";
}
TEST(ControllerRunner, LimitIsReappliedAfterSwappingController)
@@ -261,7 +263,8 @@ TEST(ControllerRunner, LimitIsReappliedAfterSwappingController)
robot_geometry_msgs::Twist cmd;
ASSERT_TRUE(fixture.runner_.computeVelocityCommands(cmd));
EXPECT_NEAR(cmd.linear.x, 0.07, 1e-9) << "đổi controller làm mất trần vận tốc đang có hiệu lực";
EXPECT_NEAR(cmd.linear.x, 0.07, 1e-9) << "swapping the controller dropped the active velocity "
"limit";
}
TEST(ControllerRunner, ControllerRefusingLimitsReportsFalse)
@@ -274,6 +277,26 @@ TEST(ControllerRunner, ControllerRefusingLimitsReportsFalse)
EXPECT_FALSE(fixture.runner_.setTwistAngular(vec(0.0, 0.0, 0.10)));
}
TEST(ControllerRunner, RefreshActivePlannerReinitializesItAndRestoresThePlan)
{
// Local planners như HybridController copy footprint vào cache trong initialize(). Refresh phải
// tạo instance mới, nhưng không được làm mất goal/plan giữa mission đang chạy.
Fixture fixture("TestControllerFootprintProbe");
ASSERT_TRUE(fixture.ok()) << fixture.error();
ASSERT_TRUE(fixture.runner_.setPlan(makePlan()));
robot_geometry_msgs::Twist before;
ASSERT_TRUE(fixture.runner_.computeVelocityCommands(before));
ASSERT_GT(before.linear.x, 0.0);
ASSERT_TRUE(fixture.runner_.refreshActivePlanner());
robot_geometry_msgs::Twist after;
ASSERT_TRUE(fixture.runner_.computeVelocityCommands(after));
EXPECT_GT(after.linear.x, before.linear.x)
<< "planner was not recreated, or its active goal/plan was not restored";
}
TEST(ControllerRunner, NonFiniteLimitIsRejected)
{
Fixture fixture;
@@ -301,7 +324,8 @@ TEST(ControllerRunner, MeasuredVelocityReachesThePlugin)
robot_geometry_msgs::Twist cmd;
ASSERT_TRUE(fixture.runner_.computeVelocityCommands(cmd));
EXPECT_NEAR(cmd.linear.x, kBaseSpeed + 0.30, 1e-9) << "vận tốc đo được không tới được plugin";
EXPECT_NEAR(cmd.linear.x, kBaseSpeed + 0.30, 1e-9) << "measured velocity never reached the "
"plugin";
}
TEST(ControllerRunner, NonFiniteMeasuredVelocityIsDroppedAndTheOldValueKept)
@@ -373,7 +397,7 @@ TEST(ControllerRunner, NaNCommandIsBlockedAtTheBoundary)
robot_geometry_msgs::Twist cmd;
EXPECT_FALSE(fixture.runner_.computeVelocityCommands(cmd));
EXPECT_TRUE(std::isfinite(cmd.linear.x)) << "lệnh chứa NaN vẫn được ghi ra ngoài";
EXPECT_TRUE(std::isfinite(cmd.linear.x)) << "a command containing NaN was still written out";
}
TEST(ControllerRunner, ExceptionFromThePluginIsContained)
@@ -399,6 +423,46 @@ TEST(ControllerRunner, CommandIsClearedBeforeEveryAttempt)
EXPECT_NEAR(cmd.angular.z, 0.0, 1e-9);
}
// ================================================================================================
// setDockingMarker — kênh marker của chặng docking
//
// Docking planner đọc `maker_name` đúng MỘT lần trong initialize() (getMaker). Bản cũ dlopen lại
// planner mỗi lần dock nên luôn thấy giá trị mới; ControllerRunner cache instance nên phải tự dựng
// lại khi marker đổi — không làm là robot dock vào marker của chặng TRƯỚC, lặng lẽ.
// ================================================================================================
TEST(ControllerRunnerDockingMarker, RejectsMarkerNotInMakerSources)
{
Fixture fixture;
ASSERT_TRUE(fixture.ok()) << fixture.error();
EXPECT_FALSE(fixture.runner_.setDockingMarker("khong_ton_tai"));
// Marker hợp lệ (test/config: `maker_sources: dock_a dock_b`) phải qua.
EXPECT_TRUE(fixture.runner_.setDockingMarker("dock_a"));
}
TEST(ControllerRunnerDockingMarker, CachedPlannerIsReinitializedWhenMarkerChanges)
{
Fixture fixture;
ASSERT_TRUE(fixture.ok()) << fixture.error();
ASSERT_TRUE(fixture.runner_.setDockingMarker("dock_a"));
ASSERT_TRUE(fixture.runner_.swapPlanner("TestControllerMarkerProbe"));
ASSERT_TRUE(fixture.runner_.setPlan(makePlan()));
robot_geometry_msgs::Twist cmd;
ASSERT_TRUE(fixture.runner_.computeVelocityCommands(cmd));
EXPECT_NEAR(cmd.linear.x, 0.11, 1e-9) << "probe could not read maker_name='dock_a' at init";
// Đổi marker rồi swap lại CÙNG planner: instance cache phải được dựng lại để initialize() đọc
// giá trị mới. Nếu không, lệnh vẫn mang mã của dock_a — chính là "dock vào nhầm trạm".
ASSERT_TRUE(fixture.runner_.setDockingMarker("dock_b"));
ASSERT_TRUE(fixture.runner_.swapPlanner("TestControllerMarkerProbe"));
ASSERT_TRUE(fixture.runner_.setPlan(makePlan()));
ASSERT_TRUE(fixture.runner_.computeVelocityCommands(cmd));
EXPECT_NEAR(cmd.linear.x, 0.22, 1e-9)
<< "instance cache kept the old maker_name — it must re-init when the marker changes";
}
int main(int argc, char** argv)
{
setenv("PNKX_NAV_CORE_CONFIG_DIR", MOVE_BASE2_TEST_CONFIG_DIR, 0);

View File

@@ -27,6 +27,7 @@
#include <move_base2/ports/action_port.h>
#include <move_base2/ports/clock_port.h>
#include <move_base2/ports/controller_port.h>
#include <move_base2/ports/costmap_status_port.h>
#include <move_base2/ports/mission_port.h>
#include <move_base2/ports/planner_port.h>
#include <move_base2/ports/pose_port.h>
@@ -173,6 +174,7 @@ public:
++make_plan_count_;
saw_order_ = saw_order_ || order != nullptr;
order_history_.push_back(order != nullptr);
in_flight_ = true;
pending_tag_ = tag;
@@ -276,6 +278,11 @@ public:
return saw_order_;
}
const std::vector<bool>& orderHistory() const
{
return order_history_;
}
private:
/// Hết kịch bản thì giữ kết quả cuối; kịch bản rỗng thì luôn thành công.
PlannerScript nextAction()
@@ -307,6 +314,7 @@ private:
std::size_t make_plan_count_ = 0;
std::size_t swap_count_ = 0;
bool saw_order_ = false;
std::vector<bool> order_history_;
};
// ------------------------------------------------------------------------------------------------
@@ -324,10 +332,21 @@ public:
return true;
}
void setTolerance(double xy_m, double yaw_rad) override
bool setDockingMarker(const std::string& marker) override
{
xy_tolerance_ = xy_m;
yaw_tolerance_ = yaw_rad;
last_docking_marker_ = marker;
return docking_marker_succeeds_;
}
void setDockingMarkerSucceeds(bool succeeds)
{
docking_marker_succeeds_ = succeeds;
}
/// @brief Marker của lời gọi setDockingMarker gần nhất; rỗng nếu chưa từng gọi.
const std::string& lastDockingMarker() const
{
return last_docking_marker_;
}
bool setPlan(const std::vector<robot_geometry_msgs::PoseStamped>& plan) override
@@ -472,16 +491,6 @@ public:
return last_plan_size_;
}
double xyTolerance() const
{
return xy_tolerance_;
}
double yawTolerance() const
{
return yaw_tolerance_;
}
private:
robot_nav_2d_msgs::Path2D local_plan_;
robot_geometry_msgs::Twist measured_velocity_;
@@ -509,9 +518,9 @@ private:
std::string active_;
bool swap_succeeds_ = true;
bool set_plan_succeeds_ = true;
bool docking_marker_succeeds_ = true;
std::string last_docking_marker_;
double nominal_speed_ = 0.3; ///< [m/s]
double xy_tolerance_ = 0.0; ///< [m]
double yaw_tolerance_ = 0.0; ///< [rad]
std::size_t set_plan_count_ = 0;
std::size_t compute_count_ = 0;
@@ -814,6 +823,37 @@ private:
// ------------------------------------------------------------------------------------------------
/**
* @class FakeCostmapStatusPort
* @brief Costmap "còn hạn / hết hạn" bật tắt được, cho guard không-đi-mù.
*
* Mặc định **còn hạn**: một cổng giả im lặng chặn robot sẽ làm mọi test khác fail vì lý do không
* liên quan tới thứ chúng đang kiểm.
*/
class FakeCostmapStatusPort final : public CostmapStatusPort
{
public:
bool isCurrent() const override
{
++query_count_;
return current_;
}
void setCurrent(bool current)
{
current_ = current;
}
std::size_t queryCount() const
{
return query_count_;
}
private:
bool current_ = true;
mutable std::size_t query_count_ = 0;
};
class FakeMissionPort final : public MissionPort
{
public:

View File

@@ -96,6 +96,71 @@ TEST(MissionAdapterBridgeConversion, ActionOnlyMissionKeepsHasGoalFalse)
EXPECT_EQ(request.actions.size(), 3U);
}
TEST(MissionAdapterBridgeConversion, CarriesDockingMarkerIndependentlyOfGoalFrame)
{
auto mission = makeMission(10);
mission->motion_hint = "docking";
mission->marker = "charger";
mission->goal_frame = "charger_02_goal";
const NavigationRequest request = MissionAdapterBridge::toRequest(*mission);
EXPECT_EQ(request.profile, move_base2::MotionProfile::kDocking);
EXPECT_EQ(request.marker, "charger");
EXPECT_EQ(request.goal_frame, "charger_02_goal");
}
TEST(MissionAdapterBridgeConversion, OrderLegCarriesItsOwnNodesAndEdges)
{
// Global planner của profile position (`CustomPlanner`) CHỈ hiện thực nhánh
// makePlan(Order, ...); nhánh ba tham số của nó là stub trả false. Chặng đi xuống mà không mang
// order thì fail ngay lượt lập plan đầu và chạy thẳng vào recovery cho tới ABORTED — đã xảy ra
// thật ngày 2026-07-31.
auto mission = makeMission(4, 2.0);
mission->type = mission_adapters::MissionType::VDA5050_ORDER;
robot_protocol_msgs::Node n0;
n0.nodeId = "n0";
robot_protocol_msgs::Node n1;
n1.nodeId = "n1";
mission->nodes = { n0, n1 };
robot_protocol_msgs::Edge e0;
e0.edgeId = "e0";
e0.startNodeId = "n0";
e0.endNodeId = "n1";
e0.trajectory.degree = 1;
mission->edges = { e0 };
const NavigationRequest request = MissionAdapterBridge::toRequest(*mission);
ASSERT_NE(request.order, nullptr);
ASSERT_EQ(request.order->nodes.size(), 2U);
EXPECT_EQ(request.order->nodes[1].nodeId, "n1");
// Edge của CHẶNG, không phải của cả order: planner tra edge theo startNodeId/endNodeId trong tập
// node nó nhận được, nên tập hai bên phải khớp nhau.
ASSERT_EQ(request.order->edges.size(), 1U);
EXPECT_EQ(request.order->edges[0].startNodeId, "n0");
EXPECT_EQ(request.order->edges[0].trajectory.degree, 1U);
}
TEST(MissionAdapterBridgeConversion, SimpleGoalMissionCarriesNoOrder)
{
const auto mission = makeMission(2); // mặc định SIMPLE_GOAL
const NavigationRequest request = MissionAdapterBridge::toRequest(*mission);
EXPECT_EQ(request.order, nullptr);
}
TEST(MissionAdapterBridgeConversion, ActionOnlyLegCarriesNoOrder)
{
// Không có quãng đường nào để lập plan, nên cũng không có gì để đưa cho planner.
auto mission = makeMission(3, 0.0, 1, /*has_goal=*/false);
mission->type = mission_adapters::MissionType::VDA5050_ORDER;
const NavigationRequest request = MissionAdapterBridge::toRequest(*mission);
EXPECT_EQ(request.order, nullptr);
}
TEST(MissionAdapterBridgeConversion, LeavesToleranceAtProfileDefault)
{
// Quy ước của NavigationRequest: sai số <= 0 nghĩa "dùng default của profile trong config".
@@ -103,8 +168,6 @@ TEST(MissionAdapterBridgeConversion, LeavesToleranceAtProfileDefault)
const auto mission = makeMission(1);
const NavigationRequest request = MissionAdapterBridge::toRequest(*mission);
EXPECT_FALSE(request.tolerance.hasXy());
EXPECT_FALSE(request.tolerance.hasYaw());
}
// ================================================================================================
@@ -118,7 +181,8 @@ TEST(MissionAdapterBridge, DispatchDoesNotReachNavigationUntilPumped)
Fixture fixture;
ASSERT_TRUE(fixture.bridge_.dispatch(makeMission(3)));
EXPECT_TRUE(fixture.received_.empty()) << "dispatch đi thẳng xuống navigation, bỏ qua biên thread";
EXPECT_TRUE(fixture.received_.empty()) << "dispatch went straight down to navigation, skipping "
"the thread boundary";
EXPECT_TRUE(fixture.bridge_.pumpPendingRequest());
ASSERT_EQ(fixture.received_.size(), 1U);
@@ -139,7 +203,7 @@ TEST(MissionAdapterBridge, EachMissionIsPushedDownExactlyOnce)
ASSERT_TRUE(fixture.bridge_.dispatch(makeMission(4)));
EXPECT_TRUE(fixture.bridge_.pumpPendingRequest());
EXPECT_FALSE(fixture.bridge_.pumpPendingRequest()) << "cùng một mission bị đẩy xuống hai lần";
EXPECT_FALSE(fixture.bridge_.pumpPendingRequest()) << "the same mission was pushed down twice";
EXPECT_EQ(fixture.received_.size(), 1U);
}
@@ -192,7 +256,7 @@ TEST(MissionAdapterBridge, OverwritingAWaitingMissionIsCounted)
ASSERT_TRUE(fixture.bridge_.pumpPendingRequest());
ASSERT_EQ(fixture.received_.size(), 1U);
EXPECT_EQ(fixture.received_[0].mission_sequence_id, 2U) << "mission cũ thắng mission mới";
EXPECT_EQ(fixture.received_[0].mission_sequence_id, 2U) << "the old mission won over the new one";
}
// ================================================================================================
@@ -238,7 +302,8 @@ TEST(MissionAdapterBridge, DirectGoalWithoutMissionIdIsNotReported)
fixture.bridge_.reportOutcome(0, NavigationOutcome::kSucceeded);
EXPECT_EQ(fixture.bridge_.staleOutcomes(), 0U) << "goal trực tiếp bị đem báo lên mission layer";
EXPECT_EQ(fixture.bridge_.staleOutcomes(), 0U) << "a direct goal was reported up to the mission "
"layer";
}
TEST(MissionAdapterBridge, SuccessReachesTheManagerAsNavigationDone)
@@ -249,13 +314,13 @@ TEST(MissionAdapterBridge, SuccessReachesTheManagerAsNavigationDone)
manager.submit({ makeMission(0) });
const auto running = manager.nextMission();
ASSERT_TRUE(running) << "manager không giao mission nào để chạy";
ASSERT_TRUE(running) << "manager handed over no mission to run";
fixture.bridge_.reportOutcome(running->id, NavigationOutcome::kSucceeded);
EXPECT_EQ(fixture.bridge_.staleOutcomes(), 0U);
EXPECT_EQ(manager.currentMissionId(), mission_adapters::kInvalidMissionId)
<< "mission vẫn còn đang chạy sau khi đã báo hoàn tất";
<< "mission is still running after completion was reported";
}
TEST(MissionAdapterBridge, OutcomeForAMissionThatIsNoLongerRunningIsCounted)

368
test/mission_layer_test.cpp Normal file
View File

@@ -0,0 +1,368 @@
/*********************************************************************
*
* Software License Agreement (BSD License)
*
* move_base2 — test MissionLayer lắp với MissionAdapterBridge thật.
*
* Đây là chỗ kiểm thứ mà `mission_adapter_bridge_test` không kiểm được: bridge có được nối vào một
* mission layer ĐANG CHẠY hay không, và một yêu cầu nhiều chặng có thật sự đi hết chặng này tới
* chặng kia hay không. Bridge đúng mà layer không được dựng thì mọi test bridge vẫn xanh trong khi
* robot chỉ chạy được chặng đầu — đúng trạng thái của gói trước lần sửa này.
*
* Nguồn mission ở đây được đăng ký thẳng vào registry (không qua Boost.DLL): đường nạp `.so` đã có
* `plugin_registry_test` của `mission_adapters` lo, còn thứ cần khoá tại đây là chuỗi sự kiện.
*
* Author: DuongTD
*********************************************************************/
#include <gtest/gtest.h>
#include <chrono>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include <mission_adapters/mission_request.h>
#include <mission_adapters/types.h>
#include <move_base2/bridges/mission_layer.h>
namespace
{
using mission_adapters::ConversionResult;
using mission_adapters::Mission;
using mission_adapters::MissionRequest;
using mission_adapters::MissionSourceAdapter;
using mission_adapters::MissionState;
using mission_adapters::SubmitMode;
using move_base2::MissionAdapterBridge;
using move_base2::MissionLayer;
using move_base2::NavigationOutcome;
using move_base2::NavigationRequest;
constexpr auto kTimeout = std::chrono::seconds(2);
constexpr auto kPollStep = std::chrono::milliseconds(2);
/**
* @brief Nguồn giả cắt một pose thành @c legs chặng, mô phỏng đúng hình dạng của một VDA5050 order
* nhiều node có action.
*/
class SplittingAdapter : public MissionSourceAdapter
{
public:
explicit SplittingAdapter(std::size_t legs, SubmitMode mode = SubmitMode::kReplace)
: legs_(legs), mode_(mode)
{
}
bool configure(const std::string&, robot::NodeHandle&) override
{
return true;
}
std::string schema() const override
{
return mission_adapters::schema::kPoseStamped;
}
bool validate(const MissionRequest& request, std::string& reason) const override
{
if (!request.pose)
{
reason = "request carries no pose";
return false;
}
return true;
}
ConversionResult convert(const MissionRequest& request) override
{
ConversionResult result;
result.mode = mode_;
for (std::size_t i = 0; i < legs_; ++i)
{
auto mission = std::make_shared<Mission>();
mission->has_goal = true;
mission->goal = *request.pose;
// Mỗi chặng xa hơn chặng trước một mét — đủ để test phân biệt được chặng nào đang chạy.
mission->goal.pose.position.x = static_cast<double>(i + 1); // [m]
result.missions.push_back(mission);
}
return result;
}
private:
std::size_t legs_;
SubmitMode mode_;
};
robot_geometry_msgs::PoseStamped makeGoal()
{
robot_geometry_msgs::PoseStamped goal;
goal.header.frame_id = "map";
goal.pose.orientation.w = 1.0;
return goal;
}
/// @brief Layer + bridge đã nối, cộng chỗ nhận yêu cầu như control thread thật.
class Fixture
{
public:
explicit Fixture(std::size_t legs, SubmitMode mode = SubmitMode::kReplace)
{
adapter_ = std::make_shared<SplittingAdapter>(legs, mode);
EXPECT_TRUE(layer_.registry().registerAdapter(adapter_));
layer_.markActiveForTesting();
layer_.attach(bridge_);
bridge_.setRequestCallback([this](const NavigationRequest& request) {
received_.push_back(request);
});
bridge_.setCancelCallback([this]() { ++cancel_calls_; });
bridge_.start();
layer_.start();
}
~Fixture()
{
layer_.stop();
bridge_.stop();
}
/**
* @brief Quay control thread cho tới khi một chặng được đẩy xuống, hoặc hết thời gian chờ.
* @return false nếu không có chặng nào tới — dùng để khẳng định "KHÔNG được có chặng mới".
*/
bool pumpUntilRequest()
{
const auto deadline = std::chrono::steady_clock::now() + kTimeout;
while (std::chrono::steady_clock::now() < deadline)
{
if (bridge_.pumpPendingRequest())
{
return true;
}
std::this_thread::sleep_for(kPollStep);
}
return false;
}
/// @brief Chờ mission layer đạt tới trạng thái mong đợi.
bool waitForState(MissionState expected)
{
const auto deadline = std::chrono::steady_clock::now() + kTimeout;
while (std::chrono::steady_clock::now() < deadline)
{
if (layer_.state() == expected)
{
return true;
}
std::this_thread::sleep_for(kPollStep);
}
return false;
}
/// @brief Báo kết cục của chặng vừa nhận, đúng như ControlLoop làm ở cuối một phiên.
void finishLastLeg(NavigationOutcome outcome)
{
ASSERT_FALSE(received_.empty());
bridge_.reportOutcome(received_.back().mission_sequence_id, outcome);
}
MissionLayer layer_;
MissionAdapterBridge bridge_;
std::vector<NavigationRequest> received_;
int cancel_calls_ = 0;
std::shared_ptr<SplittingAdapter> adapter_;
};
// ================================================================================================
// Chuỗi nhiều chặng — lý do lớp này tồn tại
// ================================================================================================
TEST(MissionLayerTest, ThreeLegOrderReachesNavigationOneLegAtATime)
{
Fixture fixture(3);
ASSERT_TRUE(fixture.layer_.submitGoal(makeGoal()));
// Chặng 1
ASSERT_TRUE(fixture.pumpUntilRequest());
ASSERT_EQ(1u, fixture.received_.size());
EXPECT_DOUBLE_EQ(1.0, fixture.received_[0].goal.pose.position.x);
EXPECT_NE(0u, fixture.received_[0].mission_sequence_id);
// Chặng 2 chỉ được giao SAU khi chặng 1 báo xong: hàng đợi tuần tự, không phải bắn hết một lượt.
fixture.finishLastLeg(NavigationOutcome::kSucceeded);
ASSERT_TRUE(fixture.pumpUntilRequest());
ASSERT_EQ(2u, fixture.received_.size());
EXPECT_DOUBLE_EQ(2.0, fixture.received_[1].goal.pose.position.x);
// Chặng 3
fixture.finishLastLeg(NavigationOutcome::kSucceeded);
ASSERT_TRUE(fixture.pumpUntilRequest());
ASSERT_EQ(3u, fixture.received_.size());
EXPECT_DOUBLE_EQ(3.0, fixture.received_[2].goal.pose.position.x);
// Mỗi chặng một id riêng: outcome trễ của chặng cũ không thể được tính cho chặng mới.
EXPECT_NE(fixture.received_[0].mission_sequence_id, fixture.received_[1].mission_sequence_id);
EXPECT_NE(fixture.received_[1].mission_sequence_id, fixture.received_[2].mission_sequence_id);
fixture.finishLastLeg(NavigationOutcome::kSucceeded);
EXPECT_TRUE(fixture.waitForState(MissionState::COMPLETED));
EXPECT_FALSE(fixture.layer_.hasMission());
}
TEST(MissionLayerTest, MissionStillPendingBetweenLegs)
{
Fixture fixture(2);
ASSERT_TRUE(fixture.layer_.submitGoal(makeGoal()));
ASSERT_TRUE(fixture.pumpUntilRequest());
// Đây là tín hiệu mà NavigationServer dùng để KHÔNG báo SUCCEEDED cho host giữa hai chặng. Sai ở
// đây nghĩa là fleet master nghe "đã tới node cuối" khi robot mới đi được nửa tuyến.
fixture.finishLastLeg(NavigationOutcome::kSucceeded);
EXPECT_TRUE(fixture.bridge_.hasActiveMission());
ASSERT_TRUE(fixture.pumpUntilRequest());
fixture.finishLastLeg(NavigationOutcome::kSucceeded);
EXPECT_TRUE(fixture.waitForState(MissionState::COMPLETED));
EXPECT_FALSE(fixture.bridge_.hasActiveMission());
}
// ================================================================================================
// Đường lỗi và đường huỷ
// ================================================================================================
TEST(MissionLayerTest, FailedLegClearsTheRestOfTheQueue)
{
Fixture fixture(3);
ASSERT_TRUE(fixture.layer_.submitGoal(makeGoal()));
ASSERT_TRUE(fixture.pumpUntilRequest());
// clear_queue_on_failure mặc định true: không tới được node n thì chạy tiếp chặng n+1 là cắt ngang
// đoạn đường fleet manager chưa cho phép đi.
fixture.finishLastLeg(NavigationOutcome::kFailed);
EXPECT_TRUE(fixture.waitForState(MissionState::FAILED));
EXPECT_FALSE(fixture.pumpUntilRequest());
EXPECT_EQ(1u, fixture.received_.size());
}
TEST(MissionLayerTest, CancelStopsNavigationAndDropsTheQueue)
{
Fixture fixture(3);
ASSERT_TRUE(fixture.layer_.submitGoal(makeGoal()));
ASSERT_TRUE(fixture.pumpUntilRequest());
fixture.layer_.cancel();
EXPECT_TRUE(fixture.waitForState(MissionState::CANCELLED));
// Huỷ phải dừng được robot, không chỉ xoá hàng đợi trong bộ nhớ (A3).
const auto deadline = std::chrono::steady_clock::now() + kTimeout;
while (fixture.cancel_calls_ == 0 && std::chrono::steady_clock::now() < deadline)
{
std::this_thread::sleep_for(kPollStep);
}
EXPECT_GE(fixture.cancel_calls_, 1);
EXPECT_FALSE(fixture.pumpUntilRequest());
EXPECT_EQ(1u, fixture.received_.size());
}
TEST(MissionLayerTest, PausedLayerHoldsTheQueueUntilResume)
{
Fixture fixture(2);
fixture.layer_.pause();
ASSERT_TRUE(fixture.waitForState(MissionState::PAUSED));
// Yêu cầu tới trong lúc người vận hành đang chủ động dừng: nhận vào hàng đợi nhưng KHÔNG tự chạy.
ASSERT_TRUE(fixture.layer_.submitGoal(makeGoal()));
EXPECT_FALSE(fixture.pumpUntilRequest());
EXPECT_TRUE(fixture.received_.empty());
fixture.layer_.resume();
EXPECT_TRUE(fixture.pumpUntilRequest());
EXPECT_EQ(1u, fixture.received_.size());
}
// ================================================================================================
// Định tuyến: layer chỉ nhận thứ nó có nguồn để xử lý
// ================================================================================================
TEST(MissionLayerTest, RejectsSchemaWithNoRegisteredSource)
{
Fixture fixture(1);
// Không có nguồn nào khai schema `vda5050.order` — layer phải TỪ CHỐI để NavigationServer biết
// đường mà rơi về nhánh trực tiếp, thay vì nuốt order rồi im lặng.
robot_protocol_msgs::Order order;
EXPECT_FALSE(fixture.layer_.handles(mission_adapters::schema::kVda5050Order));
EXPECT_FALSE(fixture.layer_.submitOrder(order));
}
TEST(MissionLayerTest, RejectsEverythingBeforeStartAndAfterStop)
{
MissionLayer layer;
MissionAdapterBridge bridge;
auto adapter = std::make_shared<SplittingAdapter>(1);
ASSERT_TRUE(layer.registry().registerAdapter(adapter));
layer.markActiveForTesting();
layer.attach(bridge);
// Chưa start: không được nhận việc mà sẽ không ai chạy.
EXPECT_FALSE(layer.submitGoal(makeGoal()));
bridge.start();
layer.start();
EXPECT_TRUE(layer.submitGoal(makeGoal()));
layer.stop();
bridge.stop();
EXPECT_FALSE(layer.submitGoal(makeGoal()));
}
TEST(MissionLayerTest, InactiveLayerNeverStarts)
{
// Không nguồn nào -> configure sẽ hỏng ở runtime thật; ở đây kiểm bất biến tương ứng: layer không
// active thì start() là no-op và mọi đường vào đều đóng.
MissionLayer layer;
MissionAdapterBridge bridge;
layer.attach(bridge);
layer.start();
EXPECT_FALSE(layer.active());
EXPECT_FALSE(layer.started());
EXPECT_EQ(0u, layer.sourceCount());
EXPECT_FALSE(layer.submitGoal(makeGoal()));
}
// ================================================================================================
// Order update — phần release thêm nối tiếp, không chạy lại từ đầu
// ================================================================================================
TEST(MissionLayerTest, AppendModeDoesNotPreemptTheRunningLeg)
{
Fixture fixture(1, SubmitMode::kAppend);
ASSERT_TRUE(fixture.layer_.submitGoal(makeGoal()));
ASSERT_TRUE(fixture.pumpUntilRequest());
const std::uint64_t running_id = fixture.received_.back().mission_sequence_id;
// Bản cập nhật tới trong lúc chặng cũ đang chạy: nó phải nằm chờ, không được huỷ chặng đang đi.
ASSERT_TRUE(fixture.layer_.submitGoal(makeGoal()));
EXPECT_FALSE(fixture.pumpUntilRequest());
EXPECT_EQ(0, fixture.cancel_calls_);
fixture.finishLastLeg(NavigationOutcome::kSucceeded);
ASSERT_TRUE(fixture.pumpUntilRequest());
EXPECT_NE(running_id, fixture.received_.back().mission_sequence_id);
}
} // namespace
int main(int argc, char** argv)
{
testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View File

@@ -54,8 +54,8 @@ public:
if (!scenario.obstacles.empty())
{
error = "driver này không mô phỏng vật cản (cổng recovery là fake theo kịch bản); "
"dùng driver có recovery_core thật cho kịch bản va chạm";
error = "this driver does not simulate obstacles (the recovery port is faked by the "
"scenario); use the driver with the real recovery_core for collision scenarios";
return false;
}
@@ -76,7 +76,7 @@ public:
}
else
{
error = "planner_script không hiểu: '" + item + "'";
error = "unknown planner_script: '" + item + "'";
return false;
}
}
@@ -106,7 +106,7 @@ public:
}
else
{
error = "controller_script không hiểu: '" + item + "'";
error = "unknown controller_script: '" + item + "'";
return false;
}
}
@@ -128,7 +128,7 @@ public:
}
else
{
error = "recovery_script không hiểu: '" + item + "'";
error = "unknown recovery_script: '" + item + "'";
return false;
}
}
@@ -136,9 +136,10 @@ public:
for (const nav_test_harness::ScenarioEvent& event : scenario.events)
{
if (event.action != "cancel" && event.action != "pause" && event.action != "resume" &&
event.action != "lose_pose" && event.action != "restore_pose")
event.action != "lose_pose" && event.action != "restore_pose" &&
event.action != "sensors_stale" && event.action != "sensors_ok")
{
error = "events: action không hiểu: '" + event.action + "'";
error = "events: unknown action: '" + event.action + "'";
return false;
}
}
@@ -147,6 +148,13 @@ public:
controller_.setScript(controller_script);
recovery_.setScript(recovery_script);
// Recovery thế hệ 2 có thể tự lái. Kịch bản nào khai `recovery_velocity` thì behavior được coi
// là họ velocity; 0 nghĩa là behavior chỉ đợi/xoá costmap và lõi phải giữ nguồn vận tốc kNone.
const bool recovery_drives = std::abs(scenario.recovery_velocity) > 0.0;
recovery_.setRecoveryVelocity(recovery_drives, scenario.recovery_velocity); // [m/s]
recovery_.setDefaultOutputKind(recovery_drives ? RecoveryOutputKind::kVelocity
: RecoveryOutputKind::kNone);
pose_.setPosition(scenario.initial_pose.x, scenario.initial_pose.y);
ControlLoopConfig config;
@@ -181,6 +189,7 @@ public:
deps_.recovery = &recovery_;
deps_.mission = &mission_;
deps_.action = &action_;
deps_.costmap_status = &costmap_status_;
if (!loop_.configure(config, deps_, error))
{
@@ -269,6 +278,15 @@ private:
{
pose_.setAvailable(true);
}
else if (event.action == "sensors_stale")
{
// Observation buffer của costmap hết hạn — lõi phải ngừng cho lái bánh xe.
costmap_status_.setCurrent(false);
}
else if (event.action == "sensors_ok")
{
costmap_status_.setCurrent(true);
}
}
}
@@ -283,6 +301,7 @@ private:
FakeRecoveryPort recovery_{ 2 };
FakeMissionPort mission_;
FakeActionPort action_;
FakeCostmapStatusPort costmap_status_;
std::size_t cycle_ = 0;
bool started_ = false;

View File

@@ -12,6 +12,7 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdlib>
#include <string>
#include <vector>
@@ -19,10 +20,12 @@
#include <nav_test_harness/scenario_runner.h>
#include "move_base2_scenario_driver.h"
#include "recovery_scenario_driver.h"
namespace
{
using move_base2::testing::MoveBase2ScenarioDriver;
using move_base2::testing::RecoveryScenarioDriver;
using nav_test_harness::Scenario;
using nav_test_harness::ScenarioReport;
using nav_test_harness::ScenarioRunner;
@@ -47,13 +50,34 @@ void runScenarioFile(const std::string& path)
Scenario scenario;
std::string error;
ASSERT_TRUE(nav_test_harness::loadScenarioFile(path, scenario, error))
<< "không nạp được " << path << ": " << error;
MoveBase2ScenarioDriver driver;
ASSERT_TRUE(driver.setup(scenario, error)) << scenario.name << ": setup thất bại: " << error;
<< "could not load " << path << ": " << error;
// Chọn driver theo DỮ LIỆU của kịch bản, không theo một khoá cấu hình riêng: kịch bản khai vật
// cản nghĩa là nó nói về va chạm thật, và chỉ driver nạp recovery_core thật mới trả lời được. Đây
// cũng là lý do MoveBase2ScenarioDriver báo lỗi setup khi thấy `obstacles` thay vì chạy lặng lẽ.
ScenarioRunner runner;
const ScenarioReport report = runner.run(scenario, driver);
ScenarioReport report;
// KHÔNG gọi setup() ở đây: `ScenarioRunner::run` đã tự gọi. Gọi hai lần từng làm driver nạp
// plugin THẬT dựng registry hai lượt, và lượt cũ giữ con trỏ tới cầu nối vừa bị huỷ — use after
// free, biểu hiện ra ngoài chỉ là một dòng "không lấy được pose" trông như lỗi TF.
(void)error;
if (scenario.obstacles.empty())
{
MoveBase2ScenarioDriver driver;
report = runner.run(scenario, driver);
}
else
{
RecoveryScenarioDriver driver;
report = runner.run(scenario, driver);
EXPECT_GT(driver.startRejections(), 0u)
<< scenario.name
<< ": no behavior REFUSED to start — a test case with obstacles that never reaches a "
"safety branch is checking something other than what it describes";
}
EXPECT_TRUE(report.passed) << nav_test_harness::formatReport(report);
}
@@ -102,14 +126,22 @@ INSTANTIATE_TEST_SUITE_P(Scenarios, ScenarioFixture, ::testing::ValuesIn(scenari
TEST(ScenarioSuite, ScenarioDirectoryIsNotEmpty)
{
const std::vector<std::string> files = scenarioFiles();
EXPECT_FALSE(files.empty()) << "không tìm thấy kịch bản nào trong " << scenarioDir()
<< " — suite sẽ xanh mà không kiểm gì cả";
EXPECT_FALSE(files.empty()) << "no scenario found in " << scenarioDir()
<< " the suite would go green without checking anything";
}
} // namespace
int main(int argc, char** argv)
{
// Kịch bản có vật cản nạp plugin recovery THẬT qua Boost.DLL; ctest không mang theo biến môi
// trường của shell nên binary phải tự trỏ, giống recovery_runner_test.
#ifdef MOVE_BASE2_TEST_CONFIG_DIR
setenv("PNKX_NAV_CORE_CONFIG_DIR", MOVE_BASE2_TEST_CONFIG_DIR, 0);
#endif
#ifdef MOVE_BASE2_TEST_LIBRARY_DIR
setenv("PNKX_NAV_CORE_LIBRARY_PATH", MOVE_BASE2_TEST_LIBRARY_DIR, 0);
#endif
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View File

@@ -74,8 +74,6 @@ ControlLoopConfig baseConfig()
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;
@@ -217,7 +215,8 @@ TEST(NavigationServerTwist, ReturnsArbiterCommandNotOdometryVelocity)
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.x, 0.3, 1e-9) << "getTwist returned the measured velocity instead of "
"the command that was published";
EXPECT_NEAR(twist.velocity.theta, 0.0, 1e-9);
}
@@ -266,7 +265,8 @@ TEST(NavigationServerTwist, StampFreezesWhenIdleSoTeleopOwnsCmdVel)
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";
<< "no request has ever arrived yet the stamp is fresh — the host would publish 0 over "
"teleop";
}
TEST(NavigationServerTwist, StampKeepsFreshBrieflyAfterGoalEndsThenFreezes)
@@ -288,14 +288,16 @@ TEST(NavigationServerTwist, StampKeepsFreshBrieflyAfterGoalEndsThenFreezes)
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";
<< "the stamp freezes as soon as the leg ends — the final stop command would never be "
"published";
// 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";
<< "the grace period is over yet the stamp is still fresh — teleop would never get /cmd_vel "
"back";
}
TEST(NavigationServerTwist, StampStaysStillWhenTheControlLoopStopsRunning)
@@ -311,7 +313,8 @@ TEST(NavigationServerTwist, StampStaysStillWhenTheControlLoopStopsRunning)
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";
<< "the timestamp refreshes itself although the control loop is not running — the host would "
"think the command is still valid";
}
TEST(NavigationServerTwist, IsStampedWithTheConfiguredRobotBaseFrame)
@@ -355,7 +358,7 @@ TEST(NavigationServerSensors, SamplesReachTheCostmapLayersOnceAttached)
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->count(), 2U) << "laser + pointcloud2 must both reach the VoxelLayer";
EXPECT_EQ(local_voxel->records()[0].topic, "/b_scan");
EXPECT_EQ(local_voxel->records()[1].topic, "/camera/depth/points_proc");
}
@@ -388,7 +391,8 @@ TEST(NavigationServerSensors, StaticMapReceivedBeforeAttachIsReplayed)
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";
ASSERT_EQ(static_layer->count(), 1U) << "a static map received before a costmap was attached "
"must not be replayed";
EXPECT_EQ(static_layer->records()[0].topic, "/map");
}
@@ -421,7 +425,7 @@ TEST(NavigationServerSensors, ReplayDoesNotDuplicateAMapAlreadyReceivedThroughTh
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";
EXPECT_EQ(static_layer->count(), 1U) << "the same map was replayed twice";
}
TEST(NavigationServerSensors, StaleLaserScansAreNotReplayedOnAttach)
@@ -475,11 +479,11 @@ TEST(NavigationServerSensors, StoredLaserScanIsTheSameOneHandedToTheCostmap)
{
if (std::isnan(stored[i]))
{
EXPECT_TRUE(std::isnan(seen_by_layer[i])) << "lệch tại tia " << i;
EXPECT_TRUE(std::isnan(seen_by_layer[i])) << "mismatch at ray " << i;
}
else
{
EXPECT_FLOAT_EQ(stored[i], seen_by_layer[i]) << "lệch tại tia " << i;
EXPECT_FLOAT_EQ(stored[i], seen_by_layer[i]) << "mismatch at ray " << i;
}
}
}
@@ -625,7 +629,7 @@ TEST(NavigationServerLifecycle, PauseTakesEffectOnTheNextCycleNotImmediately)
fixture.server_.pause();
EXPECT_EQ(fixture.server_.loop().state(), NavigationState::kControlling)
<< "pause() đi thẳng vào lõi từ thread host";
<< "pause() goes straight into the core from the host thread";
fixture.spin(1);
EXPECT_EQ(fixture.server_.loop().state(), NavigationState::kPaused);
@@ -686,7 +690,7 @@ TEST(NavigationServerLifecycle, CancelWinsOverAPauseRequestedInTheSameCycle)
fixture.server_.cancel();
fixture.spin(1);
EXPECT_NE(fixture.server_.loop().state(), NavigationState::kPaused) << "pause thắng cancel";
EXPECT_NE(fixture.server_.loop().state(), NavigationState::kPaused) << "pause won over cancel";
}
TEST(NavigationServerLifecycle, LifecycleRequestIsConsumedExactlyOnce)
@@ -738,7 +742,8 @@ TEST(NavigationServerControlThread, RunsCyclesWithoutAnyoneCallingSpinOnce)
}
fixture.server_.stopControlThread();
EXPECT_TRUE(left_idle) << "goal được nhận nhưng không cycle nào chạy — thiếu control thread";
EXPECT_TRUE(left_idle) << "the goal was accepted but no cycle ran — the control thread is "
"missing";
}
TEST(NavigationServerControlThread, RefusesToStartBeforeTheLoopIsConfigured)
@@ -763,7 +768,7 @@ TEST(NavigationServerControlThread, SecondStartIsRefusedAndStopIsIdempotent)
fixture.configure();
ASSERT_TRUE(fixture.server_.startControlThread(100.0));
EXPECT_FALSE(fixture.server_.startControlThread(100.0)) << "khởi động thread thứ hai";
EXPECT_FALSE(fixture.server_.startControlThread(100.0)) << "started a second thread";
fixture.server_.stopControlThread();
fixture.server_.stopControlThread(); // không được treo hay sập
@@ -797,7 +802,7 @@ TEST(NavigationServerPlannerData, GettersDoNotShareMutableState)
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ớ";
<< "two calls returned the same memory";
EXPECT_NO_THROW({ (void)fixture.server_.getLocalData(); });
}
@@ -851,7 +856,8 @@ TEST(NavigationServerPlannerData, PlanIsStampedWithTheControlLoopClock)
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";
<< "the plan carries a timestamp from another clock than the control loop — the host would "
"treat it as stale and drop it";
}
int main(int argc, char** argv)

View File

@@ -147,7 +147,8 @@ TEST(PlannerRunner, ConfigureFailsWhenTheInitialPlannerCannotBeLoaded)
EXPECT_FALSE(runner.configure(nh, dummyCostmap(), "TestPlannerMissingLibrary", error));
EXPECT_FALSE(error.empty());
EXPECT_FALSE(runner.configured()) << "configure thất bại nhưng vẫn tự coi là đã cấu hình";
EXPECT_FALSE(runner.configured()) << "configure failed but the object still reports itself as "
"configured";
}
// ================================================================================================
@@ -175,7 +176,8 @@ TEST(PlannerRunner, SwapsBetweenPlannersAndReusesLoadedLibraries)
ASSERT_TRUE(fixture.runner_.swapPlanner("TestPlannerOk"));
EXPECT_EQ(fixture.runner_.activePlanner(), "TestPlannerOk");
EXPECT_EQ(fixture.runner_.loadedCount(), 2U) << "quay lại planner cũ mà vẫn nạp lại thư viện";
EXPECT_EQ(fixture.runner_.loadedCount(), 2U) << "switched back to the previous planner yet "
"reloaded the library";
}
TEST(PlannerRunner, FailedSwapKeepsThePreviousPlannerActive)
@@ -210,7 +212,7 @@ TEST(PlannerRunner, PlannerThatFailedToInitializeIsNotCached)
EXPECT_FALSE(fixture.runner_.swapPlanner("TestPlannerInitFails"));
EXPECT_EQ(fixture.runner_.loadedCount(), 1U)
<< "instance hỏng bị cache lại — mọi lần th sau sẽ nhận lại đúng cái hỏng đó";
<< "a broken instance was cached — every later attempt would get that same broken one back";
}
TEST(PlannerRunner, RefusesEmptyPlannerName)
@@ -349,7 +351,8 @@ TEST(PlannerRunner, SecondStartWhileOneIsInFlightIsRefused)
move_base2::PlanResult result;
ASSERT_TRUE(fixture.runner_.pollPlan(result));
EXPECT_TRUE(result.tag == 1U || (!refused && result.tag == 2U));
EXPECT_FALSE(fixture.runner_.pollPlan(result)) << "còn kết quả thứ hai trong hộp thư";
EXPECT_FALSE(fixture.runner_.pollPlan(result)) << "a second result is still sitting in the "
"mailbox";
}
TEST(PlannerRunner, CancelledPlanProducesNoResult)
@@ -367,7 +370,8 @@ TEST(PlannerRunner, CancelledPlanProducesNoResult)
move_base2::PlanResult result;
EXPECT_FALSE(fixture.runner_.pollPlan(result))
<< "lượt đã huỷ vẫn trả kết quả — bên gọi sẽ bám theo plan tới goal không còn ai yêu cầu";
<< "a cancelled attempt still returned a result — the caller would follow a plan to a goal "
"nobody asks for anymore";
}
TEST(PlannerRunner, ResultCarriesBackTheTagItWasStartedWith)

View File

@@ -65,7 +65,7 @@ public:
if (behavior_ == Behavior::kThrow)
{
throw std::runtime_error("TestGlobalPlanner được yêu cầu ném exception");
throw std::runtime_error("TestGlobalPlanner was asked to throw an exception");
}
plan.clear();

View File

@@ -19,6 +19,7 @@
* Author: DuongTD
*********************************************************************/
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstddef>
#include <limits>
@@ -51,23 +52,32 @@ class TestLocalPlanner : public robot_nav_core2::LocalPlanner
public:
enum class Behavior
{
kOk, ///< Sinh lệnh hợp lệ, phản ánh trần và vận tốc đo được.
kNoCommand, ///< computeVelocityCommands trả false.
kNaN, ///< Sinh lệnh chứa NaN — phải bị chặn tại biên.
kThrow, ///< Ném exception khi tính lệnh.
kRefusesLimits ///< setTwistLinear/Angular trả false (planner không hỗ trợ đặt trần).
kOk, ///< Sinh lệnh hợp lệ, phản ánh trần và vận tốc đo được.
kNoCommand, ///< computeVelocityCommands trả false.
kNaN, ///< Sinh lệnh chứa NaN — phải bị chặn tại biên.
kThrow, ///< Ném exception khi tính lệnh.
kRefusesLimits, ///< setTwistLinear/Angular trả false (planner không hỗ trợ đặt trần).
kMarkerProbe, ///< Đọc `maker_name` MỘT lần lúc initialize, mã hoá vào lệnh — mô phỏng
///< getMaker() của docking planner để test đường re-init khi đổi marker.
kFootprintProbe ///< Mỗi initialize có generation mới; cần reapply goal/plan mới sinh lệnh.
};
explicit TestLocalPlanner(Behavior behavior) : behavior_(behavior)
{
}
void initialize(robot::NodeHandle& /*parent*/, const std::string& name,
void initialize(robot::NodeHandle& parent, const std::string& name,
std::shared_ptr<tf3::BufferCore> /*tf*/,
robot_costmap_2d::Costmap2DROBOT* /*costmap*/) override
{
// Cố ý KHÔNG chạm tf hay costmap — xem chú thích đầu file.
name_ = name;
// Như PNKXDockingLocalPlanner::getMaker(): đọc đúng MỘT lần, không bao giờ đọc lại.
parent.param("maker_name", marker_at_init_, std::string(""));
if (behavior_ == Behavior::kFootprintProbe)
{
footprint_generation_ = ++footprint_probe_generation_;
}
}
void setGoalPose(const robot_nav_2d_msgs::Pose2DStamped& /*goal_pose*/) override
@@ -99,13 +109,22 @@ public:
switch (behavior_)
{
case Behavior::kThrow:
throw std::runtime_error("TestLocalPlanner được yêu cầu ném exception");
throw std::runtime_error("TestLocalPlanner was asked to throw an exception");
case Behavior::kNoCommand:
// Gen-2 không có cờ thành công/thất bại: "không sinh được lệnh" biểu đạt bằng exception.
throw std::runtime_error("TestLocalPlanner: không sinh được lệnh");
throw std::runtime_error("TestLocalPlanner: could not produce a command");
case Behavior::kNaN:
cmd.velocity.x = std::numeric_limits<double>::quiet_NaN();
return cmd;
case Behavior::kMarkerProbe:
// Mã hoá marker đọc được lúc initialize vào lệnh — bảng cố định, test đối chiếu.
cmd.velocity.x = marker_at_init_ == "dock_a" ? 0.11 : marker_at_init_ == "dock_b" ? 0.22 : 0.0;
return cmd;
case Behavior::kFootprintProbe:
// Nếu refresh chỉ dựng instance mà quên setGoalPose/setPlan lại, probe trả 0 thay vì lệnh
// mang generation mới. Như vậy test kiểm đồng thời cache footprint và khôi phục chặng.
cmd.velocity.x = (saw_goal_ && plan_size_ != 0) ? 0.01 * footprint_generation_ : 0.0;
return cmd;
case Behavior::kOk:
case Behavior::kRefusesLimits:
break;
@@ -181,6 +200,9 @@ public:
private:
Behavior behavior_;
std::string name_;
std::string marker_at_init_; ///< `maker_name` tại thời điểm initialize — không bao giờ đọc lại.
inline static std::atomic<unsigned int> footprint_probe_generation_{ 0 };
unsigned int footprint_generation_ = 0;
std::size_t plan_size_ = 0;
bool saw_goal_ = false;
double limit_forward_ = 0.0; ///< [m/s]
@@ -220,6 +242,16 @@ robot_nav_core2::LocalPlanner::Ptr createRefusingLimits()
return std::make_shared<TestLocalPlanner>(TestLocalPlanner::Behavior::kRefusesLimits);
}
robot_nav_core2::LocalPlanner::Ptr createMarkerProbe()
{
return std::make_shared<TestLocalPlanner>(TestLocalPlanner::Behavior::kMarkerProbe);
}
robot_nav_core2::LocalPlanner::Ptr createFootprintProbe()
{
return std::make_shared<TestLocalPlanner>(TestLocalPlanner::Behavior::kFootprintProbe);
}
} // namespace testing
} // namespace move_base2
@@ -229,3 +261,5 @@ BOOST_DLL_ALIAS(move_base2::testing::createNoCommand, TestControllerNoCommand)
BOOST_DLL_ALIAS(move_base2::testing::createNaN, TestControllerNaN)
BOOST_DLL_ALIAS(move_base2::testing::createThrowing, TestControllerThrowing)
BOOST_DLL_ALIAS(move_base2::testing::createRefusingLimits, TestControllerRefusesLimits)
BOOST_DLL_ALIAS(move_base2::testing::createMarkerProbe, TestControllerMarkerProbe)
BOOST_DLL_ALIAS(move_base2::testing::createFootprintProbe, TestControllerFootprintProbe)

View File

@@ -44,7 +44,12 @@ struct Rig
{
runner.setNamespace(ns);
robot::NodeHandle nh;
return runner.configure(nh);
if (!runner.configure(nh))
{
return false;
}
std::string error;
return runner.configureRoutes(nh, error);
}
FakeClockPort clock{1000.0};
@@ -62,6 +67,40 @@ TEST(RecoveryRunner, LoadsBehaviorsInDeclaredOrder)
EXPECT_EQ(rig.runner.behaviorName(1), "wait_long");
}
TEST(RecoveryRunner, ResolvesPerTriggerRoutesByBehaviorName)
{
Rig rig;
ASSERT_TRUE(rig.load("recovery"));
const move_base2::RecoveryRoutes& routes = rig.runner.routes();
ASSERT_EQ(routes.planning_failed.size(), 1u);
EXPECT_EQ(routes.planning_failed[0], 0u); // wait_short
ASSERT_EQ(routes.controlling_failed.size(), 2u);
EXPECT_EQ(routes.controlling_failed[0], 1u); // wait_long
EXPECT_EQ(routes.controlling_failed[1], 0u); // wait_short
ASSERT_EQ(routes.oscillation.size(), 1u);
EXPECT_EQ(routes.oscillation[0], 1u); // wait_long
}
TEST(RecoveryRunner, SkipsFuturePluginFromRoutesWithoutCreatingInvalidIndexes)
{
Rig rig;
rig.runner.setNamespace("recovery_missing_detour");
robot::NodeHandle nh;
// Registry báo false vì plugin chưa có, nhưng wait vẫn được nạp và phải dùng được.
EXPECT_FALSE(rig.runner.configure(nh));
ASSERT_EQ(rig.runner.behaviorCount(), 1u);
std::string error;
ASSERT_TRUE(rig.runner.configureRoutes(nh, error)) << error;
const move_base2::RecoveryRoutes& routes = rig.runner.routes();
ASSERT_EQ(routes.controlling_failed.size(), 1u);
EXPECT_EQ(routes.controlling_failed[0], 0u);
ASSERT_EQ(routes.oscillation.size(), 1u);
EXPECT_EQ(routes.oscillation[0], 0u);
}
TEST(RecoveryRunner, ReportsOutputKindOfLoadedBehaviors)
{
Rig rig;
@@ -210,7 +249,8 @@ TEST(RecoveryRunner, ConfigureRequiresClockAndPose)
runner.setNamespace("recovery");
robot::NodeHandle nh;
EXPECT_FALSE(runner.configure(nh)) << "thiếu ClockPort/PosePort phải hỏng ngay, không phải lúc tick";
EXPECT_FALSE(runner.configure(nh)) << "a missing ClockPort/PosePort must fail right away, not at "
"tick time";
}
TEST(RecoveryRunner, ConfigureTwiceRejected)

View File

@@ -0,0 +1,555 @@
/*********************************************************************
*
* Software License Agreement (BSD License)
*
* move_base2 — ScenarioDriver thứ hai: recovery_core THẬT trên một thế giới giả có vật cản.
*
* Vì sao cần driver thứ hai. @ref MoveBase2ScenarioDriver mô tả **hành vi của các cổng**: recovery
* của nó là một script, nên nó kiểm được "lõi phản ứng đúng khi recovery báo hỏng" nhưng không bao
* giờ kiểm được "recovery có tự phát hiện ra vật cản sau lưng hay không" — câu hỏi đó chỉ có nghĩa
* khi plugin thật chạy trên một lưới thật có vật cản thật.
*
* Ba thứ driver này có mà driver kia không có:
* 1. **Plugin thật**, nạp qua đúng đường Boost.DLL mà runtime đi.
* 2. **Lưới và va chạm thật** — `FakeCostmap` + `FakeCollisionChecker` của harness, vật cản lấy
* thẳng từ `Scenario::obstacles`.
* 3. **Robot thật sự di chuyển**: lệnh vận tốc phát ra được tích phân vào pose mỗi cycle. Không có
* phần này thì `BackUpRecovery` không bao giờ tiến tới vật cản, và ca test "lùi vào vật cản" chỉ
* là một cái tên.
*
* Author: DuongTD
*********************************************************************/
#ifndef MOVE_BASE2_TEST_RECOVERY_SCENARIO_DRIVER_H_
#define MOVE_BASE2_TEST_RECOVERY_SCENARIO_DRIVER_H_
#include <cmath>
#include <cstddef>
#include <memory>
#include <string>
#include <vector>
#include <nav_test_harness/fake_collision_checker.h>
#include <nav_test_harness/fake_costmap.h>
#include <nav_test_harness/fake_pose_provider.h>
#include <nav_test_harness/scenario.h>
#include <nav_test_harness/scenario_runner.h>
#include <recovery_core/recovery_behavior.h>
#include <recovery_core/recovery_context.h>
#include <recovery_core/recovery_registry.h>
#include <move_base2/control_loop.h>
#include "fake_ports.h"
namespace move_base2
{
namespace testing
{
/// @brief Nối `nav_test_harness::FakePoseProvider` vào cổng pose của recovery_core.
class HarnessPoseProvider final : public recovery_core::PoseProvider
{
public:
explicit HarnessPoseProvider(nav_test_harness::FakePoseProvider* fake) : fake_(fake)
{
}
bool getRobotPose(robot_geometry_msgs::PoseStamped& pose) const override
{
return fake_ != nullptr && fake_->getRobotPose(pose);
}
private:
nav_test_harness::FakePoseProvider* fake_ = nullptr; ///< non-owning
};
/// @brief Nối `nav_test_harness::FakeCollisionChecker` vào cổng va chạm của recovery_core.
class HarnessCollisionChecker final : public recovery_core::CollisionChecker
{
public:
explicit HarnessCollisionChecker(nav_test_harness::FakeCollisionChecker* fake) : fake_(fake)
{
}
double footprintCost(double x, double y, double theta) const override
{
// Không có checker thì coi như không đặt được — giả định an toàn, không phải 0.
return fake_ == nullptr ? -1.0 : fake_->footprintCost(x, y, theta);
}
private:
nav_test_harness::FakeCollisionChecker* fake_ = nullptr; ///< non-owning
};
/**
* @class RegistryRecoveryPort
* @brief Hiện thực @ref RecoveryPort trên một `recovery_core::RecoveryRegistry` đã nạp sẵn.
*
* Là bản rút gọn của @ref RecoveryRunner: cùng ánh xạ enum, cùng ngữ nghĩa tick, nhưng lấy context
* từ ngoài thay vì dựng từ `Costmap2DROBOT`. Nhờ vậy plugin thật chạy được trên lưới giả.
*
* @note Cố ý **không** dùng lại `RecoveryRunner`: cổng pose/va chạm của nó được dựng từ costmap thật
* và không bơm được từ ngoài. Thêm một seam chỉ-dành-cho-test vào lớp runtime để test dễ hơn
* là đổi runtime vì test — hướng phụ thuộc sai.
*/
class RegistryRecoveryPort final : public RecoveryPort
{
public:
explicit RegistryRecoveryPort(recovery_core::RecoveryRegistry* registry, ClockPort* clock)
: registry_(registry), clock_(clock)
{
}
bool configure(robot::NodeHandle& /*nh*/) override
{
// Registry đã được nạp bởi driver trước khi control loop chạy.
return registry_ != nullptr && registry_->size() > 0;
}
std::size_t behaviorCount() const override
{
return registry_ == nullptr ? 0 : registry_->size();
}
RecoveryOutputKind outputKind(std::size_t index) const override
{
recovery_core::RecoveryBehavior* behavior = behaviorAt(index);
if (behavior == nullptr)
{
return RecoveryOutputKind::kNone;
}
switch (behavior->outputKind())
{
case recovery_core::RecoveryOutputType::kVelocity:
return RecoveryOutputKind::kVelocity;
case recovery_core::RecoveryOutputType::kPath:
return RecoveryOutputKind::kPath;
case recovery_core::RecoveryOutputType::kNone:
break;
}
return RecoveryOutputKind::kNone;
}
bool start(std::size_t index, RecoveryTrigger trigger) override
{
active_ = behaviorAt(index);
if (active_ == nullptr || clock_ == nullptr)
{
return false;
}
recovery_core::RecoveryGoal goal;
switch (trigger)
{
case RecoveryTrigger::kPlanningFailed:
goal.trigger = recovery_core::RecoveryTrigger::kPlanningFailed;
break;
case RecoveryTrigger::kControllingFailed:
goal.trigger = recovery_core::RecoveryTrigger::kControllingFailed;
break;
case RecoveryTrigger::kOscillation:
goal.trigger = recovery_core::RecoveryTrigger::kOscillation;
break;
}
if (!active_->start(goal, clock_->now()))
{
// Đây chính là đường mà ca "vật cản sau lưng" đi qua: BackUpRecovery quét trước quãng lùi và
// TỪ CHỐI khởi động nếu đã có va chạm — robot không được nhúc nhích lấy một cycle.
++start_rejections_;
active_ = nullptr;
return false;
}
return true;
}
RecoveryTick update() override
{
RecoveryTick tick;
if (active_ == nullptr || clock_ == nullptr)
{
tick.status = RecoveryTick::Status::kFailed;
return tick;
}
const recovery_core::RecoveryResult result = active_->update(clock_->now());
switch (result.status)
{
case recovery_core::RecoveryStatus::kRunning:
tick.status = RecoveryTick::Status::kRunning;
break;
case recovery_core::RecoveryStatus::kSucceeded:
tick.status = RecoveryTick::Status::kSucceeded;
break;
case recovery_core::RecoveryStatus::kIdle:
case recovery_core::RecoveryStatus::kCancelled:
case recovery_core::RecoveryStatus::kFailed:
tick.status = RecoveryTick::Status::kFailed;
break;
}
if (const robot_geometry_msgs::Twist* velocity = result.velocity())
{
tick.has_velocity = true;
tick.cmd = *velocity;
}
tick.message = result.message;
return tick;
}
void cancel() override
{
if (active_ != nullptr)
{
active_->cancel();
}
}
std::string behaviorName(std::size_t index) const override
{
return registry_ != nullptr && index < registry_->size() ? registry_->nameAt(index)
: std::string();
}
/// @brief Số lần behavior từ chối khởi động — bằng chứng ca test đi đúng nhánh nó nói là đang kiểm.
std::size_t startRejections() const
{
return start_rejections_;
}
private:
recovery_core::RecoveryBehavior* behaviorAt(std::size_t index) const
{
return registry_ == nullptr ? nullptr : registry_->at(index);
}
recovery_core::RecoveryRegistry* registry_ = nullptr; ///< non-owning
ClockPort* clock_ = nullptr; ///< non-owning
recovery_core::RecoveryBehavior* active_ = nullptr; ///< non-owning
std::size_t start_rejections_ = 0;
};
/**
* @class RecoveryScenarioDriver
* @brief Chạy kịch bản qua @ref ControlLoop với recovery_core THẬT trên lưới giả có vật cản.
*
* Planner và controller vẫn là cổng giả theo script: ca test ở đây nói về **recovery**, và một
* planner thật sẽ làm kết quả phụ thuộc vào chất lượng đường đi thay vì vào thứ đang được kiểm.
*/
class RecoveryScenarioDriver final : public nav_test_harness::ScenarioDriver
{
public:
bool setup(const nav_test_harness::Scenario& scenario, std::string& error) override
{
scenario_ = scenario;
// Gọi setup() lần thứ hai phải dựng lại từ đầu, và THỨ TỰ tháo là bắt buộc: behavior trong
// registry giữ con trỏ tới pose/collision bridge qua RecoveryContext, nên registry phải chết
// TRƯỚC chúng. Tháo ngược lại là use-after-free, và triệu chứng của nó ("không lấy được pose")
// trông y hệt một lỗi TF bình thường.
recovery_port_.reset();
registry_.reset();
collision_bridge_.reset();
pose_bridge_.reset();
checker_.reset();
costmap_.reset();
if (scenario.obstacles.empty())
{
// Không phải lỗi chết người, nhưng nói ra: driver này tồn tại vì vật cản. Kịch bản không có
// vật cản nào chạy ở đây là đang trả giá dựng plugin thật mà không kiểm thêm được gì.
error = "RecoveryScenarioDriver is for scenarios WITH obstacles; use MoveBase2ScenarioDriver";
return false;
}
// --- Thế giới: lưới, vật cản, pose ban đầu --------------------------------------------------
costmap_.reset(new nav_test_harness::FakeCostmap(nav_test_harness::FakeCostmap::centered(
kWorldSpan, kResolution)));
for (const nav_test_harness::ScenarioObstacle& obstacle : scenario.obstacles)
{
if (costmap_->setLethalCircle(obstacle.x, obstacle.y, obstacle.radius) == 0) // [m]
{
// Vật cản nằm ngoài lưới = kịch bản dựng sai. Im lặng ở đây nghĩa là ca test chạy trên một
// thế giới không có vật cản nào và vẫn xanh.
error = "the obstacle lies outside the fake grid — re-check the coordinates in the "
"scenario";
return false;
}
}
checker_.reset(new nav_test_harness::FakeCollisionChecker(
costmap_.get(),
nav_test_harness::FakeCollisionChecker::rectangleFootprint(kFootprintLength,
kFootprintWidth)));
pose_source_.setPose(scenario.initial_pose.x, scenario.initial_pose.y,
scenario.initial_pose.theta);
pose_bridge_.reset(new HarnessPoseProvider(&pose_source_));
collision_bridge_.reset(new HarnessCollisionChecker(checker_.get()));
recovery_core::RecoveryContext ctx;
ctx.pose = pose_bridge_.get();
ctx.collision = collision_bridge_.get();
// --- Recovery THẬT --------------------------------------------------------------------------
registry_.reset(new recovery_core::RecoveryRegistry());
robot::NodeHandle nh;
if (!registry_->loadFromConfig(nh, kRecoveryNamespace, ctx))
{
error = "could not load the real recovery behaviors from namespace '" +
std::string(kRecoveryNamespace) + "' — check library_path in the test config";
return false;
}
if (registry_->size() == 0)
{
error = "the recovery namespace is empty — the scenario would run without checking anything";
return false;
}
recovery_port_.reset(new RegistryRecoveryPort(registry_.get(), &clock_));
// --- Cổng giả cho phần còn lại --------------------------------------------------------------
std::vector<PlannerScript> planner_script;
for (const std::string& item : scenario.planner_script)
{
if (item == "ok")
{
planner_script.push_back(PlannerScript::kOk);
}
else if (item == "fail")
{
planner_script.push_back(PlannerScript::kFail);
}
else if (item == "empty")
{
planner_script.push_back(PlannerScript::kEmpty);
}
else
{
error = "unknown planner_script: '" + item + "'";
return false;
}
}
planner_.setScript(planner_script);
std::vector<ControllerScript> controller_script;
for (const std::string& item : scenario.controller_script)
{
if (item == "ok")
{
controller_script.push_back(ControllerScript::kOk);
}
else if (item == "fail")
{
controller_script.push_back(ControllerScript::kFail);
}
else if (item == "goal_reached")
{
controller_script.push_back(ControllerScript::kGoalReached);
}
else
{
error = "controller_script is not supported by this driver: '" + item + "'";
return false;
}
}
controller_.setScript(controller_script);
if (!scenario.recovery_script.empty())
{
error = "recovery_script cannot be used here: recovery is a REAL plugin, its result is "
"decided by collisions and geometry, not assigned by the scenario";
return false;
}
pose_port_.setPosition(scenario.initial_pose.x, scenario.initial_pose.y);
ControlLoopConfig config;
config.nominal_control_period = scenario.control_period; // [s]
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 = registry_->size();
config.state_machine.recovery_enabled = true;
config.velocity.max_vel_x = scenario.expect_max_speed > 0.0 ? scenario.expect_max_speed : 0.5;
config.velocity.min_vel_x = -config.velocity.max_vel_x;
config.velocity.max_vel_theta =
scenario.expect_max_yaw_rate > 0.0 ? scenario.expect_max_yaw_rate : 1.0;
config.velocity.max_accel_x = 100.0; // [m/s^2] lớn: ca test kiểm va chạm, không kiểm ramp
config.velocity.max_accel_theta = 100.0; // [rad/s^2]
config.position.global_planner_name = "ScenarioGlobalPlanner";
config.position.local_planner_name = "ScenarioLocalPlanner";
config.docking = config.position;
config.go_straight = config.position;
config.rotate = config.position;
deps_.clock = &clock_;
deps_.pose = &pose_port_;
deps_.planner = &planner_;
deps_.controller = &controller_;
deps_.recovery = recovery_port_.get();
deps_.mission = &mission_;
deps_.action = &action_;
if (!loop_.configure(config, deps_, error))
{
return false;
}
NavigationRequest request;
request.profile = MotionProfile::kPosition;
request.goal.header.frame_id = "map";
request.goal.pose.position.x = scenario.goal.x; // [m]
request.goal.pose.position.y = scenario.goal.y; // [m]
request.goal.pose.orientation.z = std::sin(scenario.goal.theta * 0.5);
request.goal.pose.orientation.w = std::cos(scenario.goal.theta * 0.5);
if (!loop_.submit(request, error))
{
return false;
}
cycle_ = 0;
started_ = false;
return true;
}
bool step(nav_test_harness::ScenarioStep& step) override
{
if (!started_)
{
started_ = true;
step.cycle = 0;
step.state = toString(loop_.state());
step.linear_x = 0.0;
step.angular_z = 0.0;
return true;
}
applyEventsFor(cycle_);
const bool running = loop_.step();
const robot_geometry_msgs::Twist& command = loop_.lastCommand();
step.cycle = cycle_;
step.state = toString(loop_.state());
step.linear_x = command.linear.x; // [m/s]
step.angular_z = command.angular.z; // [rad/s]
// Robot thật sự đi theo lệnh vừa phát. Đây là điểm khác biệt của driver này: không có tích phân
// chuyển động thì pose đứng yên, `BackUpRecovery` không bao giờ đo được quãng đã lùi, và ca test
// "lùi vào vật cản" sẽ kết thúc vì hết giờ chứ không vì va chạm — xanh vì lý do sai.
integrateMotion(command, scenario_.control_period);
clock_.advance(scenario_.control_period);
++cycle_;
return running;
}
std::string outcome() const override
{
const char* text = loop_.lastOutcome();
return text != nullptr ? std::string(text) : std::string();
}
/// @brief Số lần behavior từ chối khởi động — dùng để khẳng định ca test đi đúng nhánh.
std::size_t startRejections() const
{
return recovery_port_ == nullptr ? 0 : recovery_port_->startRejections();
}
private:
/// [m] Cạnh của lưới giả, đủ rộng để quãng lùi và footprint không chạm biên.
static constexpr double kWorldSpan = 8.0;
static constexpr double kResolution = 0.05; ///< [m/ô] khớp costmap thật của workspace
static constexpr double kFootprintLength = 0.6; ///< [m]
static constexpr double kFootprintWidth = 0.4; ///< [m]
static constexpr const char* kRecoveryNamespace = "recovery_scenario";
void integrateMotion(const robot_geometry_msgs::Twist& command, double dt)
{
const double yaw = pose_source_.rawPose().theta; // [rad]
pose_source_.moveBy(command.linear.x * std::cos(yaw) * dt,
command.linear.x * std::sin(yaw) * dt, command.angular.z * dt);
// Hai nguồn pose phải đi cùng nhau: `pose_source_` là thứ recovery_core nhìn thấy, `pose_port_`
// là thứ lõi nhìn thấy. Lệch nhau thì chống quẩn và recovery nói về hai robot khác nhau.
const auto& pose = pose_source_.rawPose();
pose_port_.setPosition(pose.x, pose.y);
}
void applyEventsFor(std::size_t cycle)
{
for (const nav_test_harness::ScenarioEvent& event : scenario_.events)
{
if (event.cycle != cycle)
{
continue;
}
if (event.action == "cancel")
{
loop_.requestCancel();
}
else if (event.action == "pause")
{
loop_.requestPause();
}
else if (event.action == "resume")
{
loop_.requestResume();
}
else if (event.action == "lose_pose")
{
pose_port_.setAvailable(false);
}
else if (event.action == "restore_pose")
{
pose_port_.setAvailable(true);
}
else if (event.action == "sensors_stale")
{
costmap_status_.setCurrent(false);
}
else if (event.action == "sensors_ok")
{
costmap_status_.setCurrent(true);
}
}
}
nav_test_harness::Scenario scenario_;
std::unique_ptr<nav_test_harness::FakeCostmap> costmap_;
std::unique_ptr<nav_test_harness::FakeCollisionChecker> checker_;
nav_test_harness::FakePoseProvider pose_source_;
std::unique_ptr<HarnessPoseProvider> pose_bridge_;
std::unique_ptr<HarnessCollisionChecker> collision_bridge_;
// Khai SAU các cầu nối: thành viên bị huỷ theo thứ tự ngược, nên registry (giữ con trỏ tới chúng)
// chết trước — cùng lý do với thứ tự tháo trong setup().
std::unique_ptr<recovery_core::RecoveryRegistry> registry_;
std::unique_ptr<RegistryRecoveryPort> recovery_port_;
ControlLoop loop_;
ControlLoopDeps deps_;
FakeClockPort clock_;
FakePosePort pose_port_;
FakePlannerPort planner_;
FakeControllerPort controller_;
FakeMissionPort mission_;
FakeActionPort action_;
FakeCostmapStatusPort costmap_status_;
std::size_t cycle_ = 0;
bool started_ = false;
};
} // namespace testing
} // namespace move_base2
#endif // MOVE_BASE2_TEST_RECOVERY_SCENARIO_DRIVER_H_

184
test/runtime_stats_test.cpp Normal file
View File

@@ -0,0 +1,184 @@
/**
* @file runtime_stats_test.cpp
* @brief Kiểm @ref move_base2::RuntimeStats.
*
* Ba tính chất được khoá lại ở đây, đều là thứ mà một lỗi ở chúng sẽ làm bảng thống kê nói dối chứ
* không làm chương trình chết:
*
* 1. **Tắt là tắt hẳn** — `period <= 0` thì không đăng ký được đoạn nào, không đo, không in.
* Telemetry bật ngoài ý muốn trên robot thật nghĩa là log chen vào vòng điều khiển.
* 2. **Cửa sổ được reset sau mỗi lần in** — số liệu là của cửa sổ vừa qua, không phải tích luỹ từ
* lúc khởi động. Cộng dồn mãi thì mọi đỉnh tức thời sẽ bị pha loãng và không bao giờ thấy lại.
* 3. **Thread tự tạo được gán nhãn qua cửa sổ chụp** — đây là cơ chế duy nhất gọi tên được thread
* của costmap mà không phải sửa gói costmap.
*/
#include <move_base2/io/runtime_stats.h>
#include <atomic>
#include <chrono>
#include <thread>
#include <gtest/gtest.h>
namespace
{
using move_base2::RuntimeStats;
using move_base2::ScopedSection;
/// Đốt CPU thật trong khoảng @p ms — sleep không làm tăng bộ đếm CPU của thread.
void burnCpu(int ms)
{
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
volatile double sink = 0.0;
while (std::chrono::steady_clock::now() < deadline)
{
for (int i = 0; i < 1000; ++i)
{
sink += static_cast<double>(i) * 1.000001;
}
}
(void)sink;
}
TEST(RuntimeStatsTest, DisabledMeansNoWork)
{
RuntimeStats stats(0.0);
EXPECT_FALSE(stats.enabled());
EXPECT_EQ(stats.section("anything"), RuntimeStats::kInvalidSection);
EXPECT_FALSE(stats.tick()) << "period = 0 yet it still printed to the terminal";
// Không được crash dù chỉ số không hợp lệ.
stats.record(RuntimeStats::kInvalidSection, 1000);
stats.registerCurrentThread("must not be recorded");
const std::string table = stats.render();
EXPECT_EQ(table.find("anything"), std::string::npos);
}
TEST(RuntimeStatsTest, NegativePeriodDisables)
{
RuntimeStats stats(-1.0);
EXPECT_FALSE(stats.enabled());
}
TEST(RuntimeStatsTest, SectionAccumulatesAndResetsPerWindow)
{
RuntimeStats stats(3600.0); // chu kỳ dài: chỉ render() thủ công mới đóng cửa sổ
ASSERT_TRUE(stats.enabled());
const RuntimeStats::SectionId id = stats.section("test.section");
ASSERT_NE(id, RuntimeStats::kInvalidSection);
stats.record(id, 1'000'000); // 1 ms
stats.record(id, 3'000'000); // 3 ms
const std::string first = stats.render();
EXPECT_NE(first.find("test.section"), std::string::npos);
EXPECT_NE(first.find("2.00"), std::string::npos) << "average must be 2.00 ms:\n" << first;
EXPECT_NE(first.find("3.00"), std::string::npos) << "peak must be 3.00 ms:\n" << first;
// Cửa sổ mới: đoạn vẫn còn trong bảng nhưng số liệu về 0.
const std::string second = stats.render();
EXPECT_NE(second.find("test.section"), std::string::npos);
EXPECT_NE(second.find("0.00"), std::string::npos) << "a new window must reset:\n" << second;
}
TEST(RuntimeStatsTest, SameNameReturnsSameId)
{
RuntimeStats stats(3600.0);
EXPECT_EQ(stats.section("loop"), stats.section("loop"));
}
TEST(RuntimeStatsTest, TickOnlyPrintsWhenPeriodElapsed)
{
RuntimeStats stats(3600.0);
EXPECT_FALSE(stats.tick()) << "printed before the period elapsed";
RuntimeStats fast(0.001); // [s]
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(fast.tick());
EXPECT_FALSE(fast.tick()) << "the window must be reopened right after printing";
}
TEST(RuntimeStatsTest, RegisteredThreadAppearsWithCpuTime)
{
RuntimeStats stats(3600.0);
std::atomic<bool> registered{ false };
std::atomic<bool> stop{ false };
std::thread worker([&stats, &registered, &stop]() {
stats.registerCurrentThread("test/worker");
registered.store(true);
while (!stop.load())
{
burnCpu(5);
}
});
while (!registered.load())
{
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
burnCpu(60);
const std::string table = stats.render();
stop.store(true);
worker.join();
EXPECT_NE(table.find("test/worker"), std::string::npos) << table;
EXPECT_NE(table.find("(unregistered)"), std::string::npos)
<< "CPU outside the registered threads must show up, otherwise the table hides the culprit:\n"
<< table;
}
TEST(RuntimeStatsTest, ThreadCaptureLabelsThreadsCreatedInsideWindow)
{
RuntimeStats stats(3600.0);
std::atomic<bool> stop{ false };
std::thread created;
stats.beginThreadCapture();
created = std::thread([&stop]() {
while (!stop.load())
{
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
});
// Thread phải thực sự tồn tại trong /proc trước khi đóng cửa sổ chụp.
std::this_thread::sleep_for(std::chrono::milliseconds(20));
stats.endThreadCapture("simulated/costmap");
const std::string table = stats.render();
stop.store(true);
created.join();
#ifdef __linux__
EXPECT_NE(table.find("simulated/costmap"), std::string::npos) << table;
#else
GTEST_SKIP() << "thread capture windows rely on /proc, Linux only";
#endif
}
TEST(RuntimeStatsTest, ScopedSectionToleratesNullStats)
{
// Đây là đường đi bình thường của mọi test dùng cổng giả: runner không được gắn telemetry.
{
ScopedSection timer(nullptr, RuntimeStats::kInvalidSection);
}
RuntimeStats stats(0.0);
{
ScopedSection timer(&stats, stats.section("skipped"));
}
SUCCEED();
}
} // namespace
int main(int argc, char** argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}

View File

@@ -233,7 +233,8 @@ TEST(SensorGateway, LayerNamedAfterATopicDoesNotReceiveTheSample)
bench.gateway().pushLaserScan("/b_scan", makeScan());
EXPECT_EQ(trap->count(), 0U) << "layer trùng TÊN topic nhưng sai KIỂU vẫn nhận được dữ liệu";
EXPECT_EQ(trap->count(), 0U) << "a layer matching the topic NAME but with the wrong TYPE still "
"received data";
EXPECT_EQ(voxel->count(), 1U);
}
@@ -310,7 +311,8 @@ TEST(SensorGateway, ExceptionFromOneLayerDoesNotStarveTheNextOnes)
bench.gateway().pushLaserScan("/b_scan", makeScan());
EXPECT_EQ(exploding->count(), 0U);
EXPECT_EQ(healthy->count(), 1U) << "layer lành bị bỏ qua vì layer trước nó ném exception";
EXPECT_EQ(healthy->count(), 1U) << "a healthy layer was skipped because the layer before it "
"threw an exception";
EXPECT_EQ(bench.gateway().stats().layer_exceptions, 1U);
EXPECT_EQ(bench.gateway().stats().delivered, 1U);
}

View File

@@ -81,7 +81,7 @@ protected:
{
if (explode_)
{
throw std::runtime_error("SpyLayer được yêu cầu ném exception");
throw std::runtime_error("SpyLayer was asked to throw an exception");
}
records_.push_back(Record{ &type, topic });
if (observer_)

View File

@@ -193,7 +193,8 @@ TEST(StateMachineConfig, RejectsRecoveryEnabledWithZeroBehaviors)
config.recovery_enabled = true;
std::string error;
EXPECT_FALSE(config.validate(error)) << "cấu hình này lúc chạy sẽ ABORTED ngay ở lỗi đầu tiên";
EXPECT_FALSE(config.validate(error)) << "this config would go ABORTED at runtime on the very "
"first failure";
}
TEST(StateMachineConfig, AcceptsRecoveryDisabledWithZeroBehaviors)
@@ -206,6 +207,18 @@ TEST(StateMachineConfig, AcceptsRecoveryDisabledWithZeroBehaviors)
EXPECT_TRUE(config.validate(error)) << error;
}
TEST(StateMachineConfig, RejectsResolvedRouteWithAnOutOfRangeBehavior)
{
StateMachineConfig config = baseConfig();
config.recovery_routes.planning_failed = {0};
config.recovery_routes.controlling_failed = {1};
config.recovery_routes.oscillation = {2}; // behavior_count chỉ là 2
std::string error;
EXPECT_FALSE(config.validate(error));
EXPECT_NE(error.find("loaded behavior"), std::string::npos);
}
TEST(StateMachineConfig, DescribeMentionsEveryParameter)
{
const std::string text = baseConfig().describe();
@@ -341,6 +354,27 @@ TEST(StateMachinePlanning, MaxRetriesEscalatesBeforePatienceExpires)
EXPECT_EQ(out.recovery_trigger, RecoveryTrigger::kPlanningFailed);
}
TEST(StateMachinePlanning, UsesPlanningRouteInsteadOfRegistryOrder)
{
StateMachineConfig config = baseConfig();
config.max_planning_retries = 0;
config.planner_patience = 100.0;
config.recovery_routes.planning_failed = {1};
config.recovery_routes.controlling_failed = {0};
config.recovery_routes.oscillation = {0};
Driver driver(config);
StateMachineInput request;
request.has_pending_request = true;
driver.tick(request);
const StateMachineOutput out = driver.tick(plannerFailed());
EXPECT_EQ(out.state, NavigationState::kRecovering);
EXPECT_TRUE(out.start_recovery);
EXPECT_EQ(out.recovery_index, 1u);
EXPECT_EQ(out.recovery_trigger, RecoveryTrigger::kPlanningFailed);
}
TEST(StateMachinePlanning, PatienceDisabledMeansNeverTimesOut)
{
// `planner_patience = 0` vẫn nghĩa là "tắt đồng hồ kiên nhẫn". Nhưng từ khi planner chạy trên
@@ -359,7 +393,7 @@ TEST(StateMachinePlanning, PatienceDisabledMeansNeverTimesOut)
for (int i = 0; i < 100; ++i)
{
driver.advance(1.0);
ASSERT_EQ(driver.tick(plannerFailed()).state, NavigationState::kPlanning) << "vòng " << i;
ASSERT_EQ(driver.tick(plannerFailed()).state, NavigationState::kPlanning) << "round " << i;
}
}
@@ -475,13 +509,13 @@ TEST(StateMachineControlling, ControllerPatienceSurvivesReplanLoop)
SUCCEED();
return;
}
ASSERT_EQ(state, NavigationState::kPlanning) << "vòng " << i;
ASSERT_EQ(state, NavigationState::kPlanning) << "round " << i;
driver.advance(0.05);
ASSERT_EQ(driver.tick(planReady()).state, NavigationState::kControlling) << "vòng " << i;
ASSERT_EQ(driver.tick(planReady()).state, NavigationState::kControlling) << "round " << i;
}
FAIL() << "controller_patience không bao giờ hết hạn — vòng lặp lập-plan đã làm mới đồng hồ";
FAIL() << "controller_patience never expires — the replanning loop kept refreshing the clock";
}
TEST(StateMachineControlling, OscillationTimeoutEscalatesToRecovery)
@@ -504,6 +538,24 @@ TEST(StateMachineControlling, OscillationTimeoutEscalatesToRecovery)
EXPECT_EQ(out.recovery_trigger, RecoveryTrigger::kOscillation);
}
TEST(StateMachineControlling, UsesOscillationRouteIndependently)
{
StateMachineConfig config = baseConfig();
config.oscillation_timeout = 1.0;
config.oscillation_distance = 0.5;
config.recovery_routes.planning_failed = {0};
config.recovery_routes.controlling_failed = {0};
config.recovery_routes.oscillation = {1};
Driver driver(config);
driver.driveToControlling();
driver.advance(1.1);
const StateMachineOutput out = driver.tick(controller(ControllerFeedback::kCommandValid));
EXPECT_EQ(out.state, NavigationState::kRecovering);
EXPECT_EQ(out.recovery_trigger, RecoveryTrigger::kOscillation);
EXPECT_EQ(out.recovery_index, 1u);
}
TEST(StateMachineControlling, MovingFarEnoughResetsOscillationClock)
{
StateMachineConfig config = baseConfig();
@@ -519,8 +571,8 @@ TEST(StateMachineControlling, MovingFarEnoughResetsOscillationClock)
input.travelled_since_oscillation_reset = 0.6; // [m] đi đủ xa mỗi lần
const StateMachineOutput out = driver.tick(input);
ASSERT_EQ(out.state, NavigationState::kControlling) << "vòng " << i;
ASSERT_TRUE(out.reset_oscillation_origin) << "vòng " << i;
ASSERT_EQ(out.state, NavigationState::kControlling) << "round " << i;
ASSERT_TRUE(out.reset_oscillation_origin) << "round " << i;
}
}
@@ -597,7 +649,7 @@ TEST(StateMachineControlling, LostPoseBlocksVelocityAndEventuallyRecovers)
const StateMachineOutput first = driver.tick(blind);
EXPECT_EQ(first.velocity_source, VelocitySource::kNone)
<< "không biết robot ở đâu thì không nguồn nào được phát vận tốc";
<< "when the robot position is unknown no source may publish velocity";
EXPECT_FALSE(first.run_controller);
// Mất TF kéo dài phải dẫn tới recovery, không được treo im lặng.
@@ -764,7 +816,7 @@ TEST(StateMachineRecovering, PauseMidRecoveryCancelsBehaviorAndResumesToPlanning
EXPECT_EQ(paused.state, NavigationState::kPaused);
EXPECT_TRUE(paused.cancel_recovery)
<< "giữ một behavior dở dang qua quãng dừng dài là không an toàn";
<< "keeping a behavior half-finished across a long pause is unsafe";
StateMachineInput resume;
resume.resume_requested = true;
@@ -785,7 +837,8 @@ TEST(StateMachineRecovering, LostPoseStillTicksButBlocksVelocity)
const StateMachineOutput out = driver.tick(blind);
EXPECT_EQ(out.state, NavigationState::kRecovering);
EXPECT_TRUE(out.tick_recovery) << "vẫn tick để behavior tự báo lỗi theo contract của nó";
EXPECT_TRUE(out.tick_recovery) << "still ticked so the behavior reports its own failure per its "
"contract";
EXPECT_EQ(out.velocity_source, VelocitySource::kNone);
}
@@ -819,7 +872,7 @@ TEST(StateMachinePaused, StaysPausedIndefinitelyWithoutResume)
for (int i = 0; i < 50; ++i)
{
driver.advance(1.0);
ASSERT_EQ(driver.idleTick().state, NavigationState::kPaused) << "vòng " << i;
ASSERT_EQ(driver.idleTick().state, NavigationState::kPaused) << "round " << i;
ASSERT_EQ(driver.last().velocity_source, VelocitySource::kNone);
}
}
@@ -859,7 +912,7 @@ TEST(StateMachineTerminal, OutcomeIsReportedExactlyOnce)
for (int i = 0; i < 10; ++i)
{
const StateMachineOutput out = driver.idleTick();
ASSERT_FALSE(out.report_outcome) << "báo lại ở vòng " << i;
ASSERT_FALSE(out.report_outcome) << "reported again at round " << i;
ASSERT_EQ(out.state, NavigationState::kIdle);
}
}
@@ -910,7 +963,7 @@ TEST(StateMachineActions, ActionOnlyRequestSkipsPlanningEntirely)
EXPECT_TRUE(accepted.accept_request);
EXPECT_TRUE(accepted.start_action);
EXPECT_EQ(accepted.action_index, 0u);
EXPECT_FALSE(accepted.start_planner) << "không có goal thì không có gì để lập plan";
EXPECT_FALSE(accepted.start_planner) << "with no goal there is nothing to plan";
EXPECT_EQ(accepted.velocity_source, VelocitySource::kNone);
ASSERT_EQ(driver.tick(actionFb(ActionFeedback::kRunning)).state,
@@ -973,7 +1026,7 @@ TEST(StateMachineActions, ActionFailureAbortsWithoutRecovery)
EXPECT_EQ(out.state, NavigationState::kAborted);
EXPECT_TRUE(out.report_outcome);
EXPECT_EQ(out.outcome, NavigationOutcome::kFailed);
EXPECT_FALSE(out.start_recovery) << "recovery là công cụ phục hồi navigation, không cứu được action";
EXPECT_FALSE(out.start_recovery) << "recovery repairs navigation, it cannot rescue an action";
}
TEST(StateMachineActions, CancelDuringActionCancelsPortAndEndsCancelled)
@@ -1005,15 +1058,15 @@ TEST(StateMachineActions, PauseDuringActionFreezesWithoutCancelling)
pause.pause_requested = true;
const StateMachineOutput paused = driver.tick(pause);
EXPECT_EQ(paused.state, NavigationState::kPaused);
EXPECT_FALSE(paused.cancel_action) << "action không idempotent — tạm dừng không được huỷ nó";
EXPECT_FALSE(paused.cancel_action) << "the action is not idempotent — pausing must not cancel it";
EXPECT_FALSE(paused.tick_action);
StateMachineInput resume;
resume.resume_requested = true;
const StateMachineOutput resumed = driver.tick(resume);
EXPECT_EQ(resumed.state, NavigationState::kExecutingActions);
EXPECT_TRUE(resumed.tick_action) << "resume tick tiếp action dở dang";
EXPECT_FALSE(resumed.start_action) << "không được start lại action đã chạy dở";
EXPECT_TRUE(resumed.tick_action) << "resume keeps ticking the unfinished action";
EXPECT_FALSE(resumed.start_action) << "an action already in progress must not be started again";
}
TEST(StateMachineActions, ActionPatienceIsDisabledByDefault)
@@ -1045,7 +1098,8 @@ TEST(StateMachineActions, ActionPatienceAbortsStuckActionAndCancelsPort)
driver.advance(1.0); // Tổng 1.5 s > action_patience.
const StateMachineOutput out = driver.tick(actionFb(ActionFeedback::kRunning));
EXPECT_EQ(out.state, NavigationState::kAborted);
EXPECT_TRUE(out.cancel_action) << "phải bảo port dừng thiết bị an toàn trước khi kết thúc";
EXPECT_TRUE(out.cancel_action) << "the port must be told to stop the device safely before "
"finishing";
EXPECT_TRUE(out.report_outcome);
EXPECT_EQ(out.outcome, NavigationOutcome::kFailed);
}
@@ -1071,7 +1125,8 @@ TEST(StateMachineActions, PauseRearmsActionPatienceClock)
driver.advance(0.5); // Mới 0.5 s sau resume, chưa chạm trần.
const StateMachineOutput out = driver.tick(actionFb(ActionFeedback::kRunning));
EXPECT_EQ(out.state, NavigationState::kExecutingActions) << "resume xong không được ABORTED oan";
EXPECT_EQ(out.state, NavigationState::kExecutingActions) << "must not be wrongly ABORTED after "
"resume";
EXPECT_TRUE(out.tick_action);
}
@@ -1086,7 +1141,7 @@ TEST(StateMachineActions, ActionTicksWithoutPoseAndVelocityStaysZero)
const StateMachineOutput out = driver.tick(no_pose);
EXPECT_EQ(out.state, NavigationState::kExecutingActions);
EXPECT_TRUE(out.tick_action) << "thao tác thiết bị tại chỗ không cần định vị";
EXPECT_TRUE(out.tick_action) << "operating a device in place needs no localization";
EXPECT_EQ(out.velocity_source, VelocitySource::kNone);
}
@@ -1121,16 +1176,18 @@ TEST(StateMachineInvariants, NeverRunsControllerRecoveryOrActionSimultaneously)
const StateMachineOutput out = driver.tick(input);
ASSERT_FALSE(out.run_controller && out.tick_recovery)
<< "state " << move_base2::toString(out.state) << ": hai nguồn lệnh cùng chạy";
<< "state " << move_base2::toString(out.state) << ": two command sources running at once";
ASSERT_FALSE(out.run_controller && out.tick_action)
<< "state " << move_base2::toString(out.state) << ": controller chạy cùng action";
<< "state " << move_base2::toString(out.state) << ": controller running together with an "
"action";
ASSERT_FALSE(out.tick_recovery && out.tick_action)
<< "state " << move_base2::toString(out.state) << ": recovery chạy cùng action";
<< "state " << move_base2::toString(out.state) << ": recovery running together with an "
"action";
if (move_base2::mustBeStopped(out.state))
{
ASSERT_EQ(out.velocity_source, VelocitySource::kNone)
<< "state " << move_base2::toString(out.state) << " phải dừng";
<< "state " << move_base2::toString(out.state) << " must be stopped";
}
if (out.velocity_source == VelocitySource::kController)
{
@@ -1192,7 +1249,8 @@ TEST(StateMachineAsyncPlanner, BusyDoesNotLeavePlanning)
for (int i = 1; i <= 5; ++i)
{
driver.advance(0.05);
EXPECT_EQ(driver.tick(busy).state, NavigationState::kPlanning) << "rời PLANNING ở cycle " << i;
EXPECT_EQ(driver.tick(busy).state, NavigationState::kPlanning)
<< "left PLANNING at cycle " << i;
}
}
@@ -1218,7 +1276,7 @@ TEST(StateMachineAsyncPlanner, BusyDoesNotCountAsAFailedPlanningAttempt)
}
EXPECT_EQ(driver.machine().state(), NavigationState::kPlanning)
<< "kBusy bị tính là lượt lập plan hỏng nên đã cạn max_planning_retries";
<< "kBusy was counted as a failed planning attempt so max_planning_retries ran out";
}
TEST(StateMachineAsyncPlanner, BusyWhileControllingKeepsFollowingTheCurrentPlan)
@@ -1234,7 +1292,8 @@ TEST(StateMachineAsyncPlanner, BusyWhileControllingKeepsFollowingTheCurrentPlan)
const StateMachineOutput out = driver.tick(busy);
EXPECT_EQ(out.state, NavigationState::kControlling);
EXPECT_FALSE(out.apply_plan) << "đẩy plan xuống controller khi planner chưa có plan nào";
EXPECT_FALSE(out.apply_plan) << "pushed a plan down to the controller while the planner had no "
"plan yet";
}
TEST(StateMachineConfigTest, RejectsDisablingEveryHungPlannerDetector)
@@ -1247,7 +1306,7 @@ TEST(StateMachineConfigTest, RejectsDisablingEveryHungPlannerDetector)
std::string error;
EXPECT_FALSE(config.validate(error));
EXPECT_NE(error.find("planner treo"), std::string::npos) << error;
EXPECT_NE(error.find("hung planner"), std::string::npos) << error;
}
int main(int argc, char** argv)

View File

@@ -97,7 +97,7 @@ TEST(VelocityLimits, DescribeMarksReverseAsDisabledWhenZero)
{
VelocityLimits limits = baseLimits();
limits.min_vel_x = 0.0;
EXPECT_NE(limits.describe().find("cấm lùi"), std::string::npos);
EXPECT_NE(limits.describe().find("reversing forbidden"), std::string::npos);
}
TEST(VelocityArbiter, RefusesToEmitBeforeConfigure)
@@ -134,7 +134,7 @@ TEST(VelocityArbiter, NoneSourceEmitsExactZeroImmediately)
const auto cmd = arbiter.arbitrate(VelocitySource::kNone, twist(0.5, 0.8), kDt);
EXPECT_DOUBLE_EQ(cmd.linear.x, 0.0) << "lệnh 0 phải tức thì, không giảm tốc dần";
EXPECT_DOUBLE_EQ(cmd.linear.x, 0.0) << "a zero command must be immediate, not ramped down";
EXPECT_DOUBLE_EQ(cmd.angular.z, 0.0);
EXPECT_TRUE(arbiter.stopped());
}
@@ -161,7 +161,8 @@ TEST(VelocityArbiter, NaNIsBlockedAndCounted)
const auto cmd = arbiter.arbitrate(VelocitySource::kController, twist(nan_value, 0.3), kDt);
EXPECT_DOUBLE_EQ(cmd.linear.x, 0.0);
EXPECT_DOUBLE_EQ(cmd.angular.z, 0.0) << "một trục hỏng làm hỏng cả lệnh, không sửa từng phần";
EXPECT_DOUBLE_EQ(cmd.angular.z, 0.0) << "one broken axis breaks the whole command, it is not "
"patched per axis";
EXPECT_EQ(arbiter.nonFiniteRejections(), 1u);
}
@@ -193,7 +194,8 @@ TEST(VelocityArbiter, ReverseVelocityIsClampedToMinNotToZero)
const auto cmd = arbiter.arbitrate(VelocitySource::kController, twist(-9.0, 0.0), kDt);
EXPECT_DOUBLE_EQ(cmd.linear.x, -0.2) << "min_vel_x là trần LÙI, không phải cận dưới bằng 0";
EXPECT_DOUBLE_EQ(cmd.linear.x, -0.2) << "min_vel_x is the REVERSE limit, not a lower bound of "
"zero";
}
TEST(VelocityArbiter, ReverseIsForbiddenWhenMinVelXIsZero)
@@ -290,11 +292,11 @@ TEST(VelocityArbiter, SourceHandoverInsertsExactlyOneZeroCycle)
ASSERT_NEAR(controlling.linear.x, 0.4, 1e-9);
const auto handover = arbiter.arbitrate(VelocitySource::kRecovery, twist(-0.15, 0.0), kDt);
EXPECT_DOUBLE_EQ(handover.linear.x, 0.0) << "cycle bàn giao phải là 0";
EXPECT_DOUBLE_EQ(handover.linear.x, 0.0) << "the handover cycle must be 0";
EXPECT_EQ(arbiter.handoverCycles(), 1u);
const auto recovering = arbiter.arbitrate(VelocitySource::kRecovery, twist(-0.15, 0.0), kDt);
EXPECT_NEAR(recovering.linear.x, -0.15, 1e-9) << "chỉ đúng MỘT cycle 0, không nhiều hơn";
EXPECT_NEAR(recovering.linear.x, -0.15, 1e-9) << "exactly ONE zero cycle, no more";
}
TEST(VelocityArbiter, HandoverWorksInBothDirections)

View File

@@ -31,6 +31,7 @@ using move_base2::testing::ControllerScript;
using move_base2::testing::FakeActionPort;
using move_base2::testing::FakeClockPort;
using move_base2::testing::FakeControllerPort;
using move_base2::testing::FakeCostmapStatusPort;
using move_base2::testing::FakeMissionPort;
using move_base2::testing::FakePlannerPort;
using move_base2::testing::FakePosePort;
@@ -65,8 +66,6 @@ ControlLoopConfig baseConfig()
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";
@@ -128,6 +127,7 @@ public:
deps_.recovery = &recovery_;
deps_.mission = &mission_;
deps_.action = &action_;
deps_.costmap_status = &costmap_status_;
std::string error;
EXPECT_TRUE(loop_.configure(config, deps_, error)) << error;
@@ -175,6 +175,7 @@ public:
FakeRecoveryPort recovery_;
FakeMissionPort mission_;
FakeActionPort action_;
FakeCostmapStatusPort costmap_status_;
ControlLoopDeps deps_;
private:
@@ -218,7 +219,7 @@ TEST(ControlLoop, RefusesToConfigureWithMissingPorts)
std::string error;
EXPECT_FALSE(loop.configure(baseConfig(), deps, error));
EXPECT_NE(error.find("cổng"), std::string::npos);
EXPECT_NE(error.find("port"), std::string::npos);
EXPECT_FALSE(loop.initialized());
}
@@ -286,24 +287,89 @@ TEST(ControlLoop, RejectsRequestWhenGlobalPlannerCannotBeLoaded)
EXPECT_NE(reason.find("global planner"), std::string::npos);
}
TEST(ControlLoop, ProfileSelectsItsOwnLocalPlannerAndTolerances)
TEST(ControlLoop, ProfileSelectsItsOwnLocalPlanner)
{
Fixture fixture;
NavigationRequest docking = makeRequest(1.0);
docking.profile = MotionProfile::kDocking;
docking.marker = "dock_a";
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";
// Marker phải tới port TRƯỚC khi swap — planner đọc maker_name trong initialize().
EXPECT_EQ(fixture.controller_.lastDockingMarker(), "dock_a");
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);
}
TEST(ControlLoop, DockingMarkerProfileOverridesBothPlannersAndFallsBackToDefault)
{
ControlLoopConfig config = baseConfig();
config.docking_marker_profiles["trolley"].global_planner_name = "FakeTrolleyGlobalPlanner";
config.docking_marker_profiles["trolley"].local_planner_name = "FakeTrolleyLocalPlanner";
Fixture fixture(config);
NavigationRequest trolley = makeRequest(1.0);
trolley.profile = MotionProfile::kDocking;
trolley.marker = "trolley";
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(trolley, reason)) << reason;
EXPECT_EQ(fixture.planner_.activePlanner(), "FakeTrolleyGlobalPlanner");
EXPECT_EQ(fixture.controller_.activeController(), "FakeTrolleyLocalPlanner");
NavigationRequest unconfigured = makeRequest(1.0);
unconfigured.profile = MotionProfile::kDocking;
unconfigured.marker = "charger";
ASSERT_TRUE(fixture.loop_.submit(unconfigured, reason)) << reason;
EXPECT_EQ(fixture.planner_.activePlanner(), "FakeGlobalPlanner");
EXPECT_EQ(fixture.controller_.activeController(), "FakeDockPlanner");
}
TEST(ControlLoop, RejectsDockingRequestWithoutMarker)
{
Fixture fixture;
NavigationRequest docking = makeRequest(1.0);
docking.profile = MotionProfile::kDocking;
// marker cố ý bỏ trống — bản cũ cũng chặn tại cửa dockTo.
std::string reason;
EXPECT_FALSE(fixture.loop_.submit(docking, reason));
EXPECT_NE(reason.find("marker"), std::string::npos) << reason;
}
TEST(ControlLoop, AllowsGoalFrameStyleDockingWithoutMarkerWhenConfigured)
{
ControlLoopConfig config = baseConfig();
config.docking_requires_marker = false;
Fixture fixture(config);
NavigationRequest docking = makeRequest(1.0);
docking.profile = MotionProfile::kDocking;
std::string reason;
EXPECT_TRUE(fixture.loop_.submit(docking, reason)) << reason;
EXPECT_TRUE(fixture.controller_.lastDockingMarker().empty());
}
TEST(ControlLoop, RejectsDockingRequestWhenMarkerIsUnknown)
{
Fixture fixture;
fixture.controller_.setDockingMarkerSucceeds(false); // marker không có trong maker_sources
NavigationRequest docking = makeRequest(1.0);
docking.profile = MotionProfile::kDocking;
docking.marker = "tram_la";
std::string reason;
EXPECT_FALSE(fixture.loop_.submit(docking, reason));
EXPECT_NE(reason.find("tram_la"), std::string::npos) << reason;
}
// ================================================================================================
@@ -329,7 +395,7 @@ TEST(ControlLoop, HappyPathReachesSucceededAndReportsOnce)
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";
<< "each leg may report its outcome exactly once";
}
TEST(ControlLoop, DirectGoalWithoutMissionIdDoesNotTouchMissionLayer)
@@ -343,7 +409,7 @@ TEST(ControlLoop, DirectGoalWithoutMissionIdDoesNotTouchMissionLayer)
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";
<< "a direct goal belongs to no mission so nothing is reported to the mission layer";
}
TEST(ControlLoop, ControllerCommandIsPublishedWhileControlling)
@@ -397,9 +463,9 @@ TEST(ControlLoop, NoVelocityIsEmittedOutsideControllingAndRecovering)
if (move_base2::mustBeStopped(state))
{
ASSERT_DOUBLE_EQ(fixture.loop_.lastCommand().linear.x, 0.0)
<< "state " << move_base2::toString(state) << " cycle " << i;
<< "state " << move_base2::toString(state) << " at cycle " << i;
ASSERT_DOUBLE_EQ(fixture.loop_.lastCommand().angular.z, 0.0)
<< "state " << move_base2::toString(state) << " cycle " << i;
<< "state " << move_base2::toString(state) << " at cycle " << i;
}
if (!running)
@@ -454,7 +520,8 @@ TEST(ControlLoop, EmptyPlanIsTreatedAsFailureNotAsAValidPlan)
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_EQ(fixture.controller_.setPlanCount(), 0u) << "an empty plan must not be pushed down to "
"the controller";
EXPECT_STREQ(fixture.loop_.lastOutcome(), "FAILED");
}
@@ -476,13 +543,63 @@ TEST(ControlLoop, LostPoseStopsTheRobotImmediately)
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";
<< "losing TF must stop the robot immediately, not keep going on a stale pose";
}
// ================================================================================================
// Recovery
// ================================================================================================
TEST(ControlLoop, PrimaryPlannerFailureUsesBackupBeforeRecovery)
{
ControlLoopConfig config = baseConfig();
config.backup_global_planner_name = "FakeBackupGlobalPlanner";
// 0 nghĩa là không retry planner hiện tại. Backup vẫn phải có đúng một lượt riêng trước recovery.
config.state_machine.max_planning_retries = 0;
Fixture fixture(config);
fixture.planner_.setScript({PlannerScript::kFail, PlannerScript::kOk});
fixture.controller_.setScript({ControllerScript::kOk, ControllerScript::kGoalReached});
std::string reason;
NavigationRequest request = makeRequest(3.0, 71);
request.order = std::make_shared<robot_protocol_msgs::Order>();
ASSERT_TRUE(fixture.loop_.submit(request, reason)) << reason;
fixture.run();
EXPECT_FALSE(fixture.hitLimit());
EXPECT_EQ(fixture.planner_.activePlanner(), "FakeBackupGlobalPlanner");
EXPECT_EQ(fixture.planner_.makePlanCount(), 2u);
EXPECT_EQ(fixture.planner_.orderHistory(), (std::vector<bool>{true, false}));
EXPECT_EQ(fixture.recovery_.startCount(), 0u);
EXPECT_EQ(fixture.states(),
(std::vector<std::string>{"IDLE", "PLANNING", "CONTROLLING", "SUCCEEDED"}))
<< join(fixture.states());
}
TEST(ControlLoop, BackupPlannerFailureEscalatesToRecovery)
{
ControlLoopConfig config = baseConfig();
config.backup_global_planner_name = "FakeBackupGlobalPlanner";
config.state_machine.max_planning_retries = 0;
Fixture fixture(config);
fixture.planner_.setScript({PlannerScript::kFail, PlannerScript::kFail});
fixture.recovery_.setScript({RecoveryScript::kRunning});
std::string reason;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(3.0, 72), reason)) << reason;
for (int i = 0; i < 20 && fixture.loop_.state() != NavigationState::kRecovering; ++i)
{
fixture.stepOnce();
}
EXPECT_EQ(fixture.planner_.activePlanner(), "FakeBackupGlobalPlanner");
EXPECT_EQ(fixture.planner_.makePlanCount(), 2u);
EXPECT_EQ(fixture.loop_.state(), NavigationState::kRecovering);
EXPECT_EQ(fixture.recovery_.startCount(), 1u);
EXPECT_EQ(fixture.recovery_.lastTrigger(), RecoveryTrigger::kPlanningFailed);
}
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.
@@ -521,7 +638,7 @@ TEST(ControlLoop, PlannerFailureDrivesRecoveryThenSucceeds)
fixture.clock_.advance(kControlPeriod);
}
EXPECT_TRUE(planner_unblocked) << "không bao giờ vào recovery";
EXPECT_TRUE(planner_unblocked) << "never entered recovery";
EXPECT_EQ(states, (std::vector<std::string>{"IDLE", "PLANNING", "RECOVERING", "PLANNING",
"CONTROLLING", "SUCCEEDED"}))
<< join(states);
@@ -548,7 +665,7 @@ TEST(ControlLoop, AllRecoveriesExhaustedEndsInAbortedWithSingleReport)
<< 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";
<< "behaviors must run one after another, the first one must not repeat";
EXPECT_STREQ(fixture.loop_.lastOutcome(), "FAILED");
EXPECT_EQ(fixture.loop_.outcomeReportCount(), 1u);
EXPECT_EQ(fixture.mission_.reportCountFor(9), 1u);
@@ -566,7 +683,7 @@ TEST(ControlLoop, RecoveryRefusingToStartMovesOnToTheNextBehavior)
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";
<< "a behavior that never started successfully must not be ticked";
EXPECT_STREQ(fixture.loop_.lastOutcome(), "FAILED");
}
@@ -590,7 +707,8 @@ TEST(ControlLoop, RecoveryVelocityGoesThroughArbiterWithOneHandoverCycle)
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";
EXPECT_GE(fixture.loop_.lastCommand().linear.x, -0.2) << "the reverse command must stay "
"within the limit";
}
if (!running)
{
@@ -599,7 +717,8 @@ TEST(ControlLoop, RecoveryVelocityGoesThroughArbiterWithOneHandoverCycle)
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";
EXPECT_TRUE(saw_reverse) << "recovery published a velocity but the command never reached the "
"output";
}
TEST(ControlLoop, RecoveryDisabledAbortsOnFirstFailure)
@@ -652,7 +771,7 @@ TEST(ControlLoop, CancelWhileControllingStopsAndReportsCancelled)
}
}
EXPECT_LE(cycles_to_zero, 2) << "cmd_vel phải về 0 trong vòng 2 cycle sau khi huỷ";
EXPECT_LE(cycles_to_zero, 2) << "cmd_vel must reach 0 within 2 cycles after a cancel";
EXPECT_DOUBLE_EQ(fixture.loop_.lastCommand().linear.x, 0.0);
EXPECT_EQ(fixture.loop_.state(), NavigationState::kCancelled);
EXPECT_STREQ(fixture.loop_.lastOutcome(), "CANCELLED");
@@ -742,8 +861,8 @@ TEST(ControlLoop, ThreeSequentialMissionLegsEachReportedExactlyOnce)
}
}
ASSERT_EQ(fixture.loop_.state(), NavigationState::kSucceeded) << "chặng " << leg;
ASSERT_EQ(fixture.mission_.reportCountFor(leg), 1u) << "chặng " << leg;
ASSERT_EQ(fixture.loop_.state(), NavigationState::kSucceeded) << "leg " << leg;
ASSERT_EQ(fixture.mission_.reportCountFor(leg), 1u) << "leg " << leg;
}
EXPECT_EQ(fixture.loop_.outcomeReportCount(), 3u);
@@ -813,7 +932,7 @@ TEST(ControlLoopActions, ActionFailureAbortsAndReportsOnce)
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";
EXPECT_EQ(fixture.recovery_.startCount(), 0u) << "a failed action must not drag recovery in";
}
TEST(ControlLoopActions, SubmitRejectsActionRequestWhenActionPortMissing)
@@ -833,7 +952,7 @@ TEST(ControlLoopActions, SubmitRejectsActionRequestWhenActionPortMissing)
// 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;
EXPECT_NE(reason.find("neither goal nor action"), std::string::npos) << reason;
}
// ================================================================================================
@@ -859,7 +978,7 @@ TEST(ControlLoopAsyncPlanner, StaysInPlanningWhileThePlannerIsStillWorking)
{
fixture.stepOnce();
EXPECT_EQ(fixture.loop_.state(), NavigationState::kPlanning)
<< "rời PLANNING khi planner còn đang tính, cycle " << i;
<< "left PLANNING while the planner was still computing, cycle " << i;
}
fixture.stepOnce();
@@ -882,7 +1001,7 @@ TEST(ControlLoopAsyncPlanner, DoesNotRestartAPlanThatIsAlreadyRunning)
}
EXPECT_EQ(fixture.planner_.makePlanCount(), 1u)
<< "lượt lập plan bị khởi động lại mỗi cycle";
<< "the planning attempt was restarted every cycle";
}
TEST(ControlLoopAsyncPlanner, PlannerPatienceStillFiresWhileThePlannerIsBusy)
@@ -908,7 +1027,7 @@ TEST(ControlLoopAsyncPlanner, PlannerPatienceStillFiresWhileThePlannerIsBusy)
// 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: "
<< "the planner hung and nobody escalated the robot would sit in PLANNING forever: "
<< join(fixture.states());
EXPECT_EQ(fixture.loop_.state(), NavigationState::kAborted) << join(fixture.states());
}
@@ -935,7 +1054,7 @@ TEST(ControlLoopAsyncPlanner, KeepsFollowingTheOldPlanWhileReplanningInBackgroun
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;
<< "cmd_vel was interrupted while replanning, cycle " << i;
}
}
@@ -978,7 +1097,7 @@ TEST(ControlLoopAsyncPlanner, AcceptingANewRequestCancelsAnInFlightPlan)
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";
<< "a new request did not cancel the planning attempt of the old goal";
}
TEST(ControlLoopAsyncPlanner, FailureToStartAPlanIsTreatedAsAFailedAttempt)
@@ -1026,8 +1145,8 @@ TEST(ControlLoopPreempt, NewGoalWhileControllingReplansImmediately)
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";
<< "the new goal was queued instead of replacing the old one right away";
EXPECT_GT(fixture.planner_.makePlanCount(), plans_before) << "did not replan for the new goal";
}
TEST(ControlLoopPreempt, NewGoalWhilePlanningReplansImmediately)
@@ -1044,7 +1163,8 @@ TEST(ControlLoopPreempt, NewGoalWhilePlanningReplansImmediately)
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ỷ";
EXPECT_GE(fixture.planner_.cancelCount(), 1u) << "the planning attempt for the old goal was not "
"cancelled";
}
TEST(ControlLoopPreempt, OldMissionIsReportedPreemptedExactlyOnceUnderItsOwnId)
@@ -1063,8 +1183,10 @@ TEST(ControlLoopPreempt, OldMissionIsReportedPreemptedExactlyOnceUnderItsOwnId)
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";
EXPECT_EQ(fixture.mission_.reportCountFor(11), 1u) << "the preempted leg was not reported "
"exactly once";
EXPECT_EQ(fixture.mission_.reportCountFor(22), 0u) << "the NEW leg got its outcome reported the "
"moment it was accepted";
}
TEST(ControlLoopPreempt, PreemptedGoalStillFinishesTheNewOne)
@@ -1108,7 +1230,120 @@ TEST(ControlLoopPreempt, NewGoalDuringRecoveryCancelsTheRunningBehavior)
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";
EXPECT_GE(fixture.recovery_.cancelCount(), 1u) << "the running behavior was not told to stop";
}
// ================================================================================================
// Guard "không đi mù" — dữ liệu quan sát của costmap quá hạn
// ================================================================================================
//
// move_base thế hệ 1 có đúng guard này (`move_base.cpp:2720`) và move_base2 trước đây KHÔNG có:
// costmap hết hạn nghĩa là robot đang tránh vật cản trên một bản đồ của quá khứ.
TEST(StaleCostmap, BlocksWheelsWhileControlling)
{
Fixture fixture;
fixture.planner_.setScript({ PlannerScript::kOk });
fixture.controller_.setScript(std::vector<ControllerScript>(50, ControllerScript::kOk));
std::string error;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(2.0), error)) << error;
// Chạy tới khi đang bám plan và thực sự có lệnh khác 0.
for (int i = 0; i < 20 && fixture.loop_.state() != NavigationState::kControlling; ++i)
{
fixture.loop_.step();
}
ASSERT_EQ(fixture.loop_.state(), NavigationState::kControlling);
fixture.loop_.step();
ASSERT_GT(std::abs(fixture.loop_.lastCommand().linear.x), 0.0) << "no command to block yet";
const std::size_t controller_calls_before = fixture.controller_.computeCount();
fixture.costmap_status_.setCurrent(false);
fixture.loop_.step();
EXPECT_DOUBLE_EQ(fixture.loop_.lastCommand().linear.x, 0.0) << "sensor data is stale yet it kept "
"driving";
EXPECT_DOUBLE_EQ(fixture.loop_.lastCommand().angular.z, 0.0);
EXPECT_EQ(fixture.controller_.computeCount(), controller_calls_before)
<< "the controller must not compute a command on stale data";
EXPECT_EQ(fixture.loop_.state(), NavigationState::kControlling)
<< "the guard only blocks the wheels, it does not change state — exactly like the old "
"version";
}
TEST(StaleCostmap, ResumesWhenSensorDataBecomesCurrentAgain)
{
Fixture fixture;
fixture.planner_.setScript({ PlannerScript::kOk });
fixture.controller_.setScript(std::vector<ControllerScript>(50, ControllerScript::kOk));
std::string error;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(2.0), error)) << error;
for (int i = 0; i < 20 && fixture.loop_.state() != NavigationState::kControlling; ++i)
{
fixture.loop_.step();
}
ASSERT_EQ(fixture.loop_.state(), NavigationState::kControlling);
fixture.costmap_status_.setCurrent(false);
fixture.loop_.step();
ASSERT_DOUBLE_EQ(fixture.loop_.lastCommand().linear.x, 0.0);
fixture.costmap_status_.setCurrent(true);
fixture.loop_.step();
EXPECT_GT(std::abs(fixture.loop_.lastCommand().linear.x), 0.0)
<< "sensors are fresh again yet the robot stays still";
}
TEST(StaleCostmap, DisabledByConfigLetsTheRobotDrive)
{
ControlLoopConfig config = baseConfig();
config.require_current_costmap = false;
Fixture fixture(config);
fixture.planner_.setScript({ PlannerScript::kOk });
fixture.controller_.setScript(std::vector<ControllerScript>(50, ControllerScript::kOk));
std::string error;
ASSERT_TRUE(fixture.loop_.submit(makeRequest(2.0), error)) << error;
for (int i = 0; i < 20 && fixture.loop_.state() != NavigationState::kControlling; ++i)
{
fixture.loop_.step();
}
ASSERT_EQ(fixture.loop_.state(), NavigationState::kControlling);
fixture.costmap_status_.setCurrent(false);
fixture.loop_.step();
EXPECT_GT(std::abs(fixture.loop_.lastCommand().linear.x), 0.0)
<< "the guard is disabled by config yet it still blocked";
}
TEST(StaleCostmap, NullPortMeansNoGuard)
{
// Đường đi của mọi test cổng-giả có sẵn: không ai bơm costmap_status thì lõi coi là còn hạn.
Fixture fixture;
fixture.deps_.costmap_status = nullptr;
std::string error;
ASSERT_TRUE(fixture.loop_.configure(baseConfig(), fixture.deps_, error)) << error;
fixture.planner_.setScript({ PlannerScript::kOk });
fixture.controller_.setScript(std::vector<ControllerScript>(50, ControllerScript::kOk));
ASSERT_TRUE(fixture.loop_.submit(makeRequest(2.0), error)) << error;
for (int i = 0; i < 20 && fixture.loop_.state() != NavigationState::kControlling; ++i)
{
fixture.loop_.step();
}
ASSERT_EQ(fixture.loop_.state(), NavigationState::kControlling);
fixture.costmap_status_.setCurrent(false); // không ai hỏi nó cả
fixture.loop_.step();
EXPECT_GT(std::abs(fixture.loop_.lastCommand().linear.x), 0.0);
EXPECT_EQ(fixture.costmap_status_.queryCount(), 0u) << "the port was detached yet the core still "
"asked it";
}
int main(int argc, char** argv)