Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add dependency injection example using path generator #23

Merged
merged 1 commit into from
Sep 15, 2023

Conversation

griswaldbrooks
Copy link
Collaborator

Description

Adds gtest which refactored PathGenerator to use dependency injection

Testing

Using the container

colcon build
colcon test

Notes

Please add any notes to help reviewers understand how this code should be reviewed.

Be sure to not only request reviewers but also assign someone to the pull request so it is clear when a review is required.

Copy link
Collaborator

@bgill92 bgill92 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, I built and tested it and it works. I had a few questions, and a few comments explaining what I think is happening (so let me know if I'm incorrect), but other than that it looks good.

};

struct PathGenerator {
struct MiddlewareHandle {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason that this is public is that this middlewarehandle struct is specific for the PathGenerator class right? If I wanted one for another class, like MissionControl or something, I'm gonna have to make the MiddlewareHandle again in there?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Though, to be clear:

  • It is public so that the ros middleware and mock middleware can inherit from it
  • It is declared in the PathGenerator class because I like that style to say that it is specific to this class. Using a namespace would be equally as valid (or just making a longer name)
  • If a generic version would work, say by implementing all of the components to abstract and then wrapping those up in a parameter object would probably also work

* @brief Builds paths given a map
* @param mw The middleware handle for interacting with services
*/
PathGenerator(std::unique_ptr<MiddlewareHandle> mw) : mw_{std::move(mw)} {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The move is to ensure that PathGenerator is responsible for the lifetime of the middleware handle right? The middleware object in the original scope will now cause UB because it's been moved?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the middleware object in the original scope is accessed it will be UB... or it will throw, not sure.

The std::move is required because it's a unique_ptr. I don't think it will compile otherwise.

Comment on lines +120 to +178
if (map_.get_data().size() == 0) {
mw_->log_error("MAP IS EMPTY!!");
response->success.data = false;
response->path = std_msgs::msg::UInt8MultiArray();
return;
}
// Check to make sure start and goal fields of the request are of
// size 2
if (request->start.data.size() != 2) {
mw_->log_error("START POSITION MUST CONTAIN TWO ELEMENTS!!");
response->success.data = false;
response->path = std_msgs::msg::UInt8MultiArray();
return;
}
if (request->goal.data.size() != 2) {
mw_->log_error("GOAL POSITION MUST CONTAIN TWO ELEMENTS!!");
response->success.data = false;
response->path = std_msgs::msg::UInt8MultiArray();
return;
}
auto const start =
Position{request->start.data[0], request->start.data[1]};
auto const goal =
Position{request->goal.data[0], request->goal.data[1]};

// Generate the path
auto const path = generate_global_path(start, goal);

// Start populating the response message
auto response_path = std_msgs::msg::UInt8MultiArray();

if (path.has_value()) {
response_path.layout.dim.resize(
3, std_msgs::msg::MultiArrayDimension());

response_path.layout.dim[0].label = "rows";
response_path.layout.dim[0].size = path.value().size();
response_path.layout.dim[0].stride = path.value().size() * 2;

response_path.layout.dim[1].label = "columns";
response_path.layout.dim[1].size = 2;
response_path.layout.dim[1].stride = 1;

response_path.layout.dim[2].label = "channel";
response_path.layout.dim[2].size = 1;
response_path.layout.dim[2].stride = 1;

// Start pushing back the path only if there is one
if (path.value().size() > 0) {
for (auto const& position : path.value()) {
response_path.data.push_back(position.x);
response_path.data.push_back(position.y);
}
}
}

response->success.data = path.has_value();
response->path = response_path;
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should all of the guts be moved into it's own function for the sake of readability or does that defeat the purpose?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I think we've discussed now, moving it in to it's own function would be just fine. I didn't do it here because it just wasn't what I was highlighting.

mw_->log_error("MAP IS EMPTY!!");
response->success.data = false;
response->path = std_msgs::msg::UInt8MultiArray();
return;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where you added the return right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yessir

* @param goal The goal position
* @return The path from start to goal if it exists, std::nullopt otherwise
*/
std::optional<Path> generate_global_path(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the return type be Path or std::optional<Path>? Does it defeat the purpose to have it be optional?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the code can fail to produce an output, returning a std::optional is a great way to communicate that, at least until we have std::expected

Comment on lines +386 to +403
TEST(PathGenerator, SetMap) {
// GIVEN a path generator
auto mw = std::make_unique<MockMiddlewareHandle>();
// Capture the callback so it can be called later
PathGenerator::MiddlewareHandle::SetMapServiceCallback callback;
ON_CALL(*mw, register_set_map_service(testing::_))
.WillByDefault(testing::SaveArg<0>(&callback));

auto const path_generator = PathGenerator{std::move(mw)};

// WHEN the set map service is called
auto const request = make_costmap();
auto response = std::make_shared<example_srvs::srv::SetMap::Response>();
callback(request, response);

// THEN the path generator should successfully set the map
EXPECT_TRUE(response->success.data);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, we have callback, which is the std::function type for the set map service callback, and when register_set_map_service is called by PathGenerator, save the callback that the PathGenerator registers, and then test the callback?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly. Perfect.

Comment on lines +405 to +425
TEST(PathGenerator, NoCostmap) {
// GIVEN a path generator with a costmap
auto mw = std::make_unique<MockMiddlewareHandle>();
// Capture the path callback so it can be called later
PathGenerator::MiddlewareHandle::GeneratePathServiceCallback path_callback;
ON_CALL(*mw, register_generate_path_service(testing::_))
.WillByDefault(testing::SaveArg<0>(&path_callback));

auto const path_generator = PathGenerator{std::move(mw)};

// WHEN the generate path service is called without a costmap
auto path_request = std::make_shared<example_srvs::srv::GetPath::Request>();
path_request->start.data = {0, 0};
path_request->goal.data = {0, 0};

auto path_response = std::make_shared<example_srvs::srv::GetPath::Response>();
path_callback(path_request, path_response);

// THEN the path generator should fail
EXPECT_FALSE(path_response->success.data);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, but for the get path service, right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup

Comment on lines +434 to +440
ON_CALL(*mw_, register_set_map_service(testing::_))
.WillByDefault([&](auto const& map_callback) {
auto const map_request = make_costmap();
auto map_response =
std::make_shared<example_srvs::srv::SetMap::Response>();
map_callback(map_request, map_response);
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that when register_set_map_service is called by in the PathGenerator constructor, the argument to the register_set_map_service is input to the lambda here? In this case, map_callback, is the lambda on line 108, and this lambda uses that map_callback to set the costmap inside of the PathGenerator?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yessir

Comment on lines +452 to +462
// GIVEN a path generator with a costmap
auto const path_generator = PathGenerator{std::move(mw_)};

// WHEN the generate path service is called,
auto const path_request =
std::make_shared<example_srvs::srv::GetPath::Request>();
auto path_response = std::make_shared<example_srvs::srv::GetPath::Response>();
path_callback_(path_request, path_response);

// THEN the path generator should fail
EXPECT_FALSE(path_response->success.data);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So now we have a PathGenerator whose costmap is set due to the stuff in the PathGeneratorFixture constructor. Then we test the get path callback.

Comment on lines +465 to +481
TEST_F(PathGeneratorFixture, SameStartGoal) {
// GIVEN a path generator with a costmap
auto const path_generator = PathGenerator{std::move(mw_)};

// WHEN the generate path service is called with the same start and goal
auto path_request = std::make_shared<example_srvs::srv::GetPath::Request>();
path_request->start.data = {0, 0};
path_request->goal.data = {0, 0};
auto path_response = std::make_shared<example_srvs::srv::GetPath::Response>();
path_callback_(path_request, path_response);

// THEN the path generator should succeed
EXPECT_TRUE(path_response->success.data);
auto const expected = std::vector<Position>{{0, 0}};
// AND the path should be the same as the start
EXPECT_EQ(parseGeneratedPath(path_response->path), expected);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, but now its a different test case.

@bgill92 bgill92 assigned griswaldbrooks and unassigned bgill92 Sep 15, 2023
@griswaldbrooks griswaldbrooks merged commit 912f1b4 into ros2 Sep 15, 2023
5 checks passed
@delete-merged-branch delete-merged-branch bot deleted the di-path-generator branch September 15, 2023 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants